在线 Mobi 阅读

极客时间《人人都用得上的数字化思维课》

点击此处上传一个.mobi文件或者将.mobi文件拖放到虚线区域


简介

开篇词-打通“容器技术”的任督二脉

01-预习篇·小鲸鱼大事记(一):初出茅庐

02-预习篇·小鲸鱼大事记(二):崭露头角

03-预习篇·小鲸鱼大事记(三):群雄并起

04-预习篇·小鲸鱼大事记(四):尘埃落定

05-白话容器基础(一):从进程说开去

06-白话容器基础(二):隔离与限制

07-白话容器基础(三):深入理解容器镜像

08-白话容器基础(四):重新认识Docker容器

09-从容器到容器云:谈谈Kubernetes的本质

10-Kubernetes一键部署利器:kubeadm

11-从0到1:搭建一个完整的Kubernetes集群

12-牛刀小试:我的第一个容器化应用

13-为什么我们需要Pod?

14-深入解析Pod对象(一):基本概念

15-深入解析Pod对象(二):使用进阶

16-编排其实很简单:谈谈“控制器”模型

17-经典PaaS的记忆:作业副本与水平扩展

18-深入理解StatefulSet(一):拓扑状态

19-深入理解StatefulSet(二):存储状态

20-深入理解StatefulSet(三):有状态应用实践

21-容器化守护进程的意义:DaemonSet

22-撬动离线业务:Job与CronJob

23-声明式API与Kubernetes编程范式

24-深入解析声明式API(一):API对象的奥秘

25-深入解析声明式API(二):编写自定义控制器

26-基于角色的权限控制:RBAC

27-聪明的微创新:Operator工作原理解读

28-PV、PVC、StorageClass,这些到底在说啥?

29-PV、PVC体系是不是多此一举?从本地持久化卷谈起

30-编写自己的存储插件:FlexVolume与CSI

31-容器存储实践:CSI插件编写指南

32-浅谈容器网络

33-深入解析容器跨主机网络

34-Kubernetes网络模型与CNI网络插件

35-解读Kubernetes三层网络方案

36-为什么说Kubernetes只有softmulti-tenancy?

37-找到容器不容易:Service、DNS与服务发现

38-从外界连通Service与Service调试“三板斧”

39-谈谈Service与Ingress

40-Kubernetes的资源模型与资源管理

41-十字路口上的Kubernetes默认调度器

42-Kubernetes默认调度器调度策略解析

43-Kubernetes默认调度器的优先级与抢占机制

44-KubernetesGPU管理与DevicePlugin机制

45-幕后英雄:SIG-Node与CRI

46-解读CRI与容器运行时

47-绝不仅仅是安全:KataContainers与gVisor

48-Prometheus、MetricsServer与Kubernetes监控体系

49-CustomMetrics-让AutoScaling不再“食之无味”

50-让日志无处可逃:容器日志收集与管理

51-谈谈Kubernetes开源社区和未来走向

52-答疑:在问题中解决问题,在思考中产生思考

特别放送-2019年,容器技术生态会发生些什么?

特别放送-基于Kubernetes的云原生应用管理,到底应该怎么做?

结束语-Kubernetes:赢开发者赢天下

结课测试|这些Kubernetes的相关知识,你都掌握了吗?

简介

特别推荐


你将获得

  • 容器基础知识详解
  • 从 0 搭建 Kubernetes 集群
  • 剖析 Kubernetes 的核心特性
  • 掌握基于 Kubernetes 的容器编排

讲师介绍

张磊,曾就职于微软研究院,《Docker 容器与容器云》作者,2021年 CNCF 基金会 TOC 名单中国内唯一的入选者。

Kubernetes 社区资深成员与项目维护者,长期专注并活跃于容器集群管理与云计算数据中心领域,连续三次被微软授予该领域“最有价值专家”(MVP)称号。


课程介绍

过去几年,以 Docker、Kubernetes 为代表的容器技术已发展为一项通用技术,BAT、滴滴、京东、头条等大厂,都争相把容器和 K8S 项目作为技术重心,试图“放长线钓大鱼”。

但容器技术本身偏向运维,namespace 资源隔离、cgroups 资源限制等概念,对开发者来说,理解起来比较困难。尤其在实施 K8S 落地时,总有一些问题被反复提及,比如:

  • 为什么容器里只能跑“一个进程”?
  • 之前一直用的某个 JVM 参数,在容器里怎么不好使了?
  • 为什么 Kubernetes 不能固定 IP 地址?容器网络连不通,该如何 Debug?
  • K8S 中 StatefulSet 和 Operator 到底什么区别?PV 和 PVC 又该怎么用?

这些问题的答案和原理并不复杂,但很难一两句话解释清楚。因为容器技术涉及操作系统、网络、存储、调度、分布式原理等方方面面的知识,是个名副其实的全栈技术。

而其技术体系里那些“牵一发而动全身”的主线,比如 Linux 进程模型对容器本身的重要意义,“控制器”模式对整个 K8S 项目提纲挈领的作用等等,不会详细展现在 Docker 或 Kubernetes 官方文档中,但它们才是掌握容器技术体系的精髓所在,这也是张磊的《深入剖析 Kubernetes》专栏的核心内容。

张磊花费数月时间,经过多次改版,构建出如今的知识框架,适合所有初学者和进阶容器技术的伙伴,帮你逐层理清容器背后的技术本质与设计思想,并结合对其核心特性的剖析与实践,加深你对容器技术的理解。

本专栏共包括如下四大模块:

1. “白话”容器技术基础:用饶有趣味的解说,梳理容器技术生态的发展脉络,讲述容器技术的来龙去脉与实现原理,让你知其然,并且知其所以然。

2. Kubernetes集群的搭建与实践:以浅显易懂的语言,讲述Kubernetes集群背后的原理,并从0开始搭建一套Kubernetes集群,带你领略Kubernetes集群的“一键安装”。

3. 容器编排与Kubernetes核心特性剖析:这个模块从分布式系统设计的视角出发,归纳出这些特性中体现出来的普遍方法,然后再逐一阐述Kubernetes项目关于编排、调度和作业管理的各项核心特性。

4. Kubernetes开源社区与生态:磊哥会带你思考如何同团队一起平衡内外部需求,逐渐成为社区中不可或缺的一员。

专栏上线两年多,口碑一直不错,希望也能帮你在技术实践中发挥出 Kubernetes 最大的价值。


课程目录


适合人群

  • 具备一定服务端基础知识,对容器感兴趣的互联网从业者;
  • 想要进阶容器技术的软件开发人员;
  • 希望在容器时代大展拳脚的运维工程师和架构师;
  • 希望了解和学习容器技术背后原理的技术管理者、技术销售和市场从业者。

特别放送

免费领取福利



限时活动推荐


订阅须知

  1. 订阅成功后,推荐通过“极客时间”App端、Web端学习。
  2. 本专栏为虚拟商品,交付形式为图文+音频,一经订阅,概不退款。
  3. 订阅后分享海报,每邀一位好友订阅有现金返现。
  4. 戳此先充值再购课更划算,还有最新课表、超值赠品福利。
  5. 企业采购推荐使用“极客时间企业版”便捷安排员工学习计划,掌握团队学习仪表盘。
  6. 戳此申请学生认证,订阅课程享受原价5折优惠。
  7. 价格说明:划线价、订阅价为商品或服务的参考价,并非原价,该价格仅供参考。未划线价格为商品或服务的实时标价,具体成交价格根据商品或服务参加优惠活动,或使用优惠券、礼券、赠币等不同情形发生变化,最终实际成交价格以订单结算页价格为准。

开篇词-打通“容器技术”的任督二脉

你好,我是张磊,Kubernetes社区的一位资深成员和项目维护者。

2012年,我还在浙大读书的时候,就有幸组建了一个云计算与PaaS基础设施相关的科研团队,就这样,我从早期的Cloud Foundry社区开始,正式与容器结缘。

这几年里,我大多数时间都在Kubernetes项目里从事上游技术工作,也得以作为一名从业者和社区成员的身份,参与和亲历了容器技术从“初出茅庐”到“尘埃落定”的全过程。

而即使从2013年Docker项目发布开始算起,这次变革也不过短短5年时间,可在现如今的技术圈儿里,不懂容器,没听过Kubernetes,你还真不好意思跟人打招呼。

容器技术这样一个新生事物,完全重塑了整个云计算市场的形态。它不仅催生出了一批年轻有为的容器技术人,更培育出了一个具有相当规模的开源基础设施技术市场。

在这个市场里,不仅有Google、Microsoft等技术巨擘们厮杀至今,更有无数的国内外创业公司前仆后继。而在国内,甚至连以前对开源基础设施领域涉足不多的BAT、蚂蚁、滴滴这样的巨头们,也都从AI、云计算、微服务、基础设施等维度多管齐下,争相把容器和Kubernetes项目树立为战略重心之一。

就在这场因“容器”而起的技术变革中,Kubernetes项目已然成为容器技术的事实标准,重新定义了基础设施领域对应用编排与管理的种种可能。

2014年后,我开始以远程的方式,全职在Kubernetes和Kata Containers社区从事上游开发工作,先后发起了容器镜像亲密性调度、基于等价类的调度优化等多个核心特性,参与了容器运行时接口、安全容器沙盒等多个基础特性的设计和研发。还有幸作为主要的研发人员和维护者之一,亲历了Serverless Container概念的诞生与崛起。

在2015年,我发起和组织撰写了《Docker容器与容器云》一书,希望帮助更多的人利用容器解决实际场景中的问题。时至今日,这本书的第2版也已经出版快2年了,受到了广大容器技术读者们的好评。

2018年,我又赴西雅图,在微软研究院(MSR)云计算与存储研究组,专门从事基于Kubernetes的深度学习基础设施相关的研究工作。

我与容器打交道的这些年,一直在与关注容器生态的工程师们交流,并经常探讨容器在落地过程中遇到的问题。从这些交流中,我发现总有很多相似的问题被反复提及,比如:

  1. 为什么容器里只能跑“一个进程”?

  2. 为什么我原先一直在用的某个JVM参数,在容器里就不好使了?

  3. 为什么Kubernetes就不能固定IP地址?容器网络连不通又该如何去Debug?

  4. Kubernetes中StatefulSet和Operator到底什么区别?PV和PVC这些概念又该怎么用?

这些问题乍一看与我们平常的认知非常矛盾,但它们的答案和原理却并不复杂。不过很遗憾,对于刚刚开始学习容器的技术人员来说,它们却很难用一两句话就能解释清楚。

究其原因在于,从过去以物理机和虚拟机为主体的开发运维环境,向以容器为核心的基础设施的转变过程,并不是一次温和的改革,而是涵盖了对网络、存储、调度、操作系统、分布式原理等各个方面的容器化理解和改造。

这就导致了很多初学者,对于容器技术栈表现出来的这些难题,要么知识储备不足,要么杂乱无章、无法形成体系。这,也是很多初次参与PaaS项目的从业者们共同面临的一个困境。

其实,容器技术体系看似纷乱繁杂,却存在着很多可以“牵一发而动全身”的主线。比如,Linux的进程模型对于容器本身的重要意义;或者,“控制器”模式对整个Kubernetes项目提纲挈领的作用。

但是,这些关于Linux内核、分布式系统、网络、存储等方方面面的积累,并不会在Docker或者Kubernetes的文档中交代清楚。可偏偏就是它们,才是真正掌握容器技术体系的精髓所在,是每一位技术从业者需要悉心修炼的“内功”。

而这,也正是我开设这个专栏的初衷。

我希望借由这个专栏,给你讲清楚容器背后的这些技术本质与设计思想,并结合着对核心特性的剖析与实践,加深你对容器技术的理解。为此,我把专栏划分成了4大模块:

  1. “白话”容器技术基础: 我希望用饶有趣味的解说,给你梳理容器技术生态的发展脉络,用最通俗易懂的语言描述容器底层技术的实现方式,让你知其然,也知其所以然。

  2. Kubernetes集群的搭建与实践: Kubernetes集群号称“非常复杂”,但是如果明白了其中的架构和原理,选择了正确的工具和方法,它的搭建却也可以“一键安装”,它的应用部署也可以浅显易懂。

  3. 容器编排与Kubernetes核心特性剖析: 这是这个专栏最重要的内容。“编排”永远都是容器云项目的灵魂所在,也是Kubernetes社区持久生命力的源泉。在这一模块,我会从分布式系统设计的视角出发,抽象和归纳出这些特性中体现出来的普遍方法,然后带着这些指导思想去逐一阐述Kubernetes项目关于编排、调度和作业管理的各项核心特性。“不识庐山真面目,只缘身在此山中”,希望这样一个与众不同的角度,能够给你以全新的启发。

  4. Kubernetes开源社区与生态:“开源生态”永远都是容器技术和Kubernetes项目成功的关键。在这个模块,我会和你一起探讨,容器社区在开源软件工程指导下的演进之路;带你思考,如何同团队一起平衡内外部需求,让自己逐渐成为社区中不可或缺的一员。

我希望通过这些对容器与Kubernetes项目的逐层剖析,能够让你面对容器化浪潮时不再踌躇无措,有一种拨云见日的酣畅淋漓。

最后,我想再和你分享一个故事。

2015年我在InfoQ举办的第一届容器技术大会上,结识了当时CoreOS的布道师Kelsey Hightower,他热情地和大家一起安装和体验微信,谈笑风生间,还时不时地安利一番自家产品。

但两年后也就是2017年,Kelsey已经是全世界容器圈儿的意见领袖,是Google公司Kubernetes项目的首席布道师,而他的座右铭也变为了“只布道,不推销”。此时,就算你漂洋过海想要亲自拜会Kelsey ,恐怕也得先预约下时间了。

诚然,Kelsey 的“一夜成名”,与他的勤奋和天赋密不可分,但他对这次“容器”变革走向的准确把握却也是功不可没。这也正应了一句名言:一个人的命运啊,当然要靠自我奋斗,但是也要考虑到历史的行程。

眼下,你我可能已经错过了互联网技术大爆炸的时代,也没有在数字货币早期的狂热里分到一杯羹。可就在此时此刻,在沉寂了多年的云计算与基础设施领域,一次以“容器”为名的历史变革,正呼之欲出。这一次,我们又有什么理由作壁上观呢?

如果你也想登上“容器”这趟高速前进的列车,我相信这个专栏,可以帮助你打通学习容器技术的“任督二脉”。在专栏开始,我首先为你准备了4篇预习文章,详细地梳理了容器技术自兴起到现在的发展历程,同时也回答了“Kubernetes为什么会赢”这个重要的问题,算是我额外为你准备的一份开学礼物吧。

机会总是留给有准备的人,现在就让我们一起开启这次充满挑战的容器之旅!

01-预习篇·小鲸鱼大事记(一):初出茅庐

你好,我是张磊。我今天分享的主题是:小鲸鱼大事记之初出茅庐。

如果我问你,现今最热门的服务器端技术是什么?想必你不假思索就能回答上来:当然是容器!可是,如果现在不是2018年而是2013年,你的回答还能这么斩钉截铁么?

现在就让我们把时间拨回到五年前去看看吧。

2013年的后端技术领域,已经太久没有出现过令人兴奋的东西了。曾经被人们寄予厚望的云计算技术,也已经从当初虚无缥缈的概念蜕变成了实实在在的虚拟机和账单。而相比于如日中天的AWS和盛极一时的OpenStack,以Cloud Foundry为代表的开源PaaS项目,却成为了当时云计算技术中的一股清流。

这时,Cloud Foundry项目已经基本度过了最艰难的概念普及和用户教育阶段,吸引了包括百度、京东、华为、IBM等一大批国内外技术厂商,开启了以开源PaaS为核心构建平台层服务能力的变革。如果你有机会问问当时的云计算从业者们,他们十有八九都会告诉你:PaaS的时代就要来了!

这个说法其实一点儿没错,如果不是后来一个叫Docker的开源项目突然冒出来的话。

事实上,当时还名叫dotCloud的Docker公司,也是这股PaaS热潮中的一份子。只不过相比于Heroku、Pivotal、Red Hat等PaaS弄潮儿们,dotCloud公司实在是太微不足道了,而它的主打产品由于跟主流的Cloud Foundry社区脱节,长期以来也无人问津。眼看就要被如火如荼的PaaS风潮抛弃,dotCloud公司却做出了这样一个决定:开源自己的容器项目Docker。

显然,这个决定在当时根本没人在乎。

“容器”这个概念从来就不是什么新鲜的东西,也不是Docker公司发明的。即使在当时最热门的PaaS项目Cloud Foundry中,容器也只是其最底层、最没人关注的那一部分。说到这里,我正好以当时的事实标准Cloud Foundry为例,来解说一下PaaS技术。

PaaS项目被大家接纳的一个主要原因,就是它提供了一种名叫“应用托管”的能力。 在当时,虚拟机和云计算已经是比较普遍的技术和服务了,那时主流用户的普遍用法,就是租一批AWS或者OpenStack的虚拟机,然后像以前管理物理服务器那样,用脚本或者手工的方式在这些机器上部署应用。

当然,这个部署过程难免会碰到云端虚拟机和本地环境不一致的问题,所以当时的云计算服务,比的就是谁能更好地模拟本地服务器环境,能带来更好的“上云”体验。而PaaS开源项目的出现,就是当时解决这个问题的一个最佳方案。

举个例子,创建好虚拟机之后,运维人员只需要在这些机器上部署一个Cloud Foundry项目,然后开发者只要执行一条命令就能把本地的应用部署到云上,这条命令就是:

$ cf push "我的应用"

是不是很神奇?

事实上,像Cloud Foundry这样的PaaS项目,最核心的组件就是一套应用的打包和分发机制。 Cloud Foundry为每种主流编程语言都定义了一种打包格式,而“cf push”的作用,基本上等同于用户把应用的可执行文件和启动脚本打进一个压缩包内,上传到云上Cloud Foundry的存储中。接着,Cloud Foundry会通过调度器选择一个可以运行这个应用的虚拟机,然后通知这个机器上的Agent把应用压缩包下载下来启动。

这时候关键来了,由于需要在一个虚拟机上启动很多个来自不同用户的应用,Cloud Foundry会调用操作系统的Cgroups和Namespace机制为每一个应用单独创建一个称作“沙盒”的隔离环境,然后在“沙盒”中启动这些应用进程。这样,就实现了把多个用户的应用互不干涉地在虚拟机里批量地、自动地运行起来的目的。

这,正是PaaS项目最核心的能力。 而这些Cloud Foundry用来运行应用的隔离环境,或者说“沙盒”,就是所谓的“容器”。

而Docker项目,实际上跟Cloud Foundry的容器并没有太大不同,所以在它发布后不久,Cloud Foundry的首席产品经理James Bayer就在社区里做了一次详细对比,告诉用户Docker实际上只是一个同样使用Cgroups和Namespace实现的“沙盒”而已,没有什么特别的黑科技,也不需要特别关注。

然而,短短几个月,Docker项目就迅速崛起了。它的崛起速度如此之快,以至于Cloud Foundry以及所有的PaaS社区还没来得及成为它的竞争对手,就直接被宣告出局了。那时候,一位多年的PaaS从业者曾经如此感慨道:这简直就是一场“降维打击”啊。

难道这一次,连闯荡多年的“老江湖”James Bayer也看走眼了么?

并没有。

事实上,Docker项目确实与Cloud Foundry的容器在大部分功能和实现原理上都是一样的,可偏偏就是这剩下的一小部分不一样的功能,成了Docker项目接下来“呼风唤雨”的不二法宝。

这个功能,就是Docker镜像。

恐怕连Docker项目的作者Solomon Hykes自己当时都没想到,这个小小的创新,在短短几年内就如此迅速地改变了整个云计算领域的发展历程。

我前面已经介绍过,PaaS之所以能够帮助用户大规模部署应用到集群里,是因为它提供了一套应用打包的功能。可偏偏就是这个打包功能,却成了PaaS日后不断遭到用户诟病的一个“软肋”。

出现这个问题的根本原因是,一旦用上了PaaS,用户就必须为每种语言、每种框架,甚至每个版本的应用维护一个打好的包。这个打包过程,没有任何章法可循,更麻烦的是,明明在本地运行得好好的应用,却需要做很多修改和配置工作才能在PaaS里运行起来。而这些修改和配置,并没有什么经验可以借鉴,基本上得靠不断试错,直到你摸清楚了本地应用和远端PaaS匹配的“脾气”才能够搞定。

最后结局就是,“cf push”确实是能一键部署了,但是为了实现这个一键部署,用户为每个应用打包的工作可谓一波三折,费尽心机。

Docker镜像解决的,恰恰就是打包这个根本性的问题。 所谓Docker镜像,其实就是一个压缩包。但是这个压缩包里的内容,比PaaS的应用可执行文件+启停脚本的组合就要丰富多了。实际上,大多数Docker镜像是直接由一个完整操作系统的所有文件和目录构成的,所以这个压缩包里的内容跟你本地开发和测试环境用的操作系统是完全一样的。

这就有意思了:假设你的应用在本地运行时,能看见的环境是CentOS 7.2操作系统的所有文件和目录,那么只要用CentOS 7.2的ISO做一个压缩包,再把你的应用可执行文件也压缩进去,那么无论在哪里解压这个压缩包,都可以得到与你本地测试时一样的环境。当然,你的应用也在里面!

这就是Docker镜像最厉害的地方:只要有这个压缩包在手,你就可以使用某种技术创建一个“沙盒”,在“沙盒”中解压这个压缩包,然后就可以运行你的程序了。

更重要的是,这个压缩包包含了完整的操作系统文件和目录,也就是包含了这个应用运行所需要的所有依赖,所以你可以先用这个压缩包在本地进行开发和测试,完成之后,再把这个压缩包上传到云端运行。

在这个过程中,你完全不需要进行任何配置或者修改,因为这个压缩包赋予了你一种极其宝贵的能力:本地环境和云端环境的高度一致!

这,正是Docker镜像的精髓。

那么,有了Docker镜像这个利器,PaaS里最核心的打包系统一下子就没了用武之地,最让用户抓狂的打包过程也随之消失了。相比之下,在当今的互联网里,Docker镜像需要的操作系统文件和目录,可谓唾手可得。

所以,你只需要提供一个下载好的操作系统文件与目录,然后使用它制作一个压缩包即可,这个命令就是:

$ docker build "我的镜像"

一旦镜像制作完成,用户就可以让Docker创建一个“沙盒”来解压这个镜像,然后在“沙盒”中运行自己的应用,这个命令就是:

$ docker run "我的镜像"

当然,docker run创建的“沙盒”,也是使用Cgroups和Namespace机制创建出来的隔离环境。我会在后面的文章中,详细介绍这个机制的实现原理。

所以,Docker项目给PaaS世界带来的“降维打击”,其实是提供了一种非常便利的打包机制。这种机制直接打包了应用运行所需要的整个操作系统,从而保证了本地环境和云端环境的高度一致,避免了用户通过“试错”来匹配两种不同运行环境之间差异的痛苦过程。

而对于开发者们来说,在终于体验到了生产力解放所带来的痛快之后,他们自然选择了用脚投票,直接宣告了PaaS时代的结束。

不过,Docker项目固然解决了应用打包的难题,但正如前面所介绍的那样,它并不能代替PaaS完成大规模部署应用的职责。

遗憾的是,考虑到Docker公司是一个与自己有潜在竞争关系的商业实体,再加上对Docker项目普及程度的错误判断,Cloud Foundry项目并没有第一时间使用Docker作为自己的核心依赖,去替换自己那套饱受诟病的打包流程。

反倒是一些机敏的创业公司,纷纷在第一时间推出了Docker容器集群管理的开源项目(比如Deis和Flynn),它们一般称自己为CaaS,即Container-as-a-Service,用来跟“过时”的PaaS们划清界限。

而在2014年底的DockerCon上,Docker公司雄心勃勃地对外发布了自家研发的“Docker原生”容器集群管理项目Swarm,不仅将这波“CaaS”热推向了一个前所未有的高潮,更是寄托了整个Docker公司重新定义PaaS的宏伟愿望。

在2014年的这段巅峰岁月里,Docker公司离自己的理想真的只有一步之遥。

总结

2013~2014年,以Cloud Foundry为代表的PaaS项目,逐渐完成了教育用户和开拓市场的艰巨任务,也正是在这个将概念逐渐落地的过程中,应用“打包”困难这个问题,成了整个后端技术圈子的一块心病。

Docker项目的出现,则为这个根本性的问题提供了一个近乎完美的解决方案。这正是Docker项目刚刚开源不久,就能够带领一家原本默默无闻的PaaS创业公司脱颖而出,然后迅速占领了所有云计算领域头条的技术原因。

而在成为了基础设施领域近十年难得一见的技术明星之后,dotCloud公司则在2013年底大胆改名为Docker公司。不过,这个在当时就颇具争议的改名举动,也成为了日后容器技术圈风云变幻的一个关键伏笔。

思考题

你是否曾经研发过类似PaaS的项目?你碰到过应用打包的问题吗,又是如何解决的呢?

感谢收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

02-预习篇·小鲸鱼大事记(二):崭露头角

你好,我是张磊。我今天分享的主题是:小鲸鱼大事记之崭露头角。

在上一篇文章中,我说到,伴随着PaaS概念的逐步普及,以Cloud Foundry为代表的经典PaaS项目,开始进入基础设施领域的视野,平台化和PaaS化成了这个生态中的一个最为重要的进化趋势。

就在对开源PaaS项目落地的不断尝试中,这个领域的从业者们发现了PaaS中最为棘手也最亟待解决的一个问题:究竟如何给应用打包?

遗憾的是,无论是Cloud Foundry、OpenShift,还是Clodify,面对这个问题都没能给出一个完美的答案,反而在竞争中走向了碎片化的歧途。

而就在这时,一个并不引人瞩目的PaaS创业公司dotCloud,却选择了开源自家的一个容器项目Docker。更出人意料的是,就是这样一个普通到不能再普通的技术,却开启了一个名为“Docker”的全新时代。

你可能会有疑问,Docker项目的崛起,是不是偶然呢?

事实上,这个以“鲸鱼”为注册商标的技术创业公司,最重要的战略之一就是:坚持把“开发者”群体放在至高无上的位置。

相比于其他正在企业级市场里厮杀得头破血流的经典PaaS项目们,Docker项目的推广策略从一开始就呈现出一副“憨态可掬”的亲人姿态,把每一位后端技术人员(而不是他们的老板)作为主要的传播对象。

简洁的UI,有趣的demo,“1分钟部署一个WordPress网站”“3分钟部署一个Nginx集群”,这种同开发者之间与生俱来的亲近关系,使Docker项目迅速成为了全世界Meetup上最受欢迎的一颗新星。

在过去的很长一段时间里,相较于前端和互联网技术社区,服务器端技术社区一直是一个相对沉闷而小众的圈子。在这里,从事Linux内核开发的极客们自带“不合群”的“光环”,后端开发者们啃着多年不变的TCP/IP发着牢骚,运维更是天生注定的幕后英雄。

而Docker项目,却给后端开发者提供了走向聚光灯的机会。就比如Cgroups和Namespace这种已经存在多年却很少被人们关心的特性,在2014年和2015年竟然频繁入选各大技术会议的分享议题,就因为听众们想要知道Docker这个东西到底是怎么一回事儿。

而Docker项目之所以能取得如此高的关注,一方面正如前面我所说的那样,它解决了应用打包和发布这一困扰运维人员多年的技术难题;而另一方面,就是因为它第一次把一个纯后端的技术概念,通过非常友好的设计和封装,交到了最广大的开发者群体手里。

在这种独特的氛围烘托下,你不需要精通TCP/IP,也无需深谙Linux内核原理,哪怕只是一个前端或者网站的PHP工程师,都会对如何把自己的代码打包成一个随处可以运行的Docker镜像充满好奇和兴趣。

这种受众群体的变革,正是Docker这样一个后端开源项目取得巨大成功的关键。这也是经典PaaS项目想做却没有做好的一件事情:PaaS的最终用户和受益者,一定是为这个PaaS编写应用的开发者们,而在Docker项目开源之前,PaaS与开发者之间的关系却从未如此紧密过。

解决了应用打包这个根本性的问题,同开发者与生俱来的的亲密关系,再加上PaaS概念已经深入人心的完美契机,成为Docker这个技术上看似平淡无奇的项目一举走红的重要原因。

一时之间,“容器化”取代“PaaS化”成为了基础设施领域最炙手可热的关键词,一个以“容器”为中心的、全新的云计算市场,正呼之欲出。而作为这个生态的一手缔造者,此时的dotCloud公司突然宣布将公司名称改为“Docker”。

这个举动,在当时颇受质疑。在大家印象中,Docker只是一个开源项目的名字。可是现在,这个单词却成了Docker公司的注册商标,任何人在商业活动中使用这个单词,以及鲸鱼的Logo,都会立刻受到法律警告。

那么,Docker公司这个举动到底卖的什么药?这个问题,我不妨后面再做解读,因为相较于这件“小事儿”,Docker公司在2014年发布Swarm项目才是真正的“大事儿”。

那么,Docker公司为什么一定要发布Swarm项目呢?

通过我对Docker项目崛起背后原因的分析,你应该能发现这样一个有意思的事实:虽然通过“容器”这个概念完成了对经典PaaS项目的“降维打击”,但是Docker项目和Docker公司,兜兜转转了一年多,却还是回到了PaaS项目原本深耕了多年的那个战场:如何让开发者把应用部署在我的项目上。

没错,Docker项目从发布之初就全面发力,从技术、社区、商业、市场全方位争取到的开发者群体,实际上是为此后吸引整个生态到自家“PaaS”上的一个铺垫。只不过这时,“PaaS”的定义已经全然不是Cloud Foundry描述的那个样子,而是变成了一套以Docker容器为技术核心,以Docker镜像为打包标准的、全新的“容器化”思路。

这,正是Docker项目从一开始悉心运作“容器化”理念和经营整个Docker生态的主要目的。

而Swarm项目,正是接下来承接Docker公司所有这些努力的关键所在。

总结

今天,我着重介绍了Docker项目在短时间内迅速崛起的三个重要原因:

  1. Docker镜像通过技术手段解决了PaaS的根本性问题;

  2. Docker容器同开发者之间有着与生俱来的密切关系;

  3. PaaS概念已经深入人心的完美契机。

崭露头角的Docker公司,也终于能够以一个更加强硬的姿态来面对这个曾经无比强势,但现在却完全不知所措的云计算市场。而2014年底的DockerCon欧洲峰会,则正式拉开了Docker公司扩张的序幕。

思考题

  1. 你是否认同dotCloud公司改名并开启扩张道路的战略选择?

  2. Docker公司凭借“开源”和“开发者社群”这两个关键词完成崛起的过程,对你和你所在的团队有什么启发?

感谢收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

03-预习篇·小鲸鱼大事记(三):群雄并起

你好,我是张磊。我今天分享的主题是:小鲸鱼大事记之群雄并起。

在上一篇文章中,我剖析了Docker项目迅速走红背后的技术与非技术原因,也介绍了Docker公司开启平台化战略的野心。可是,Docker公司为什么在Docker项目已经取得巨大成功之后,却执意要重新走回那条已经让无数先驱们尘沙折戟的PaaS之路呢?

实际上,Docker项目一日千里的发展势头,一直伴随着公司管理层和股东们的阵阵担忧。他们心里明白,虽然Docker项目备受追捧,但用户们最终要部署的,还是他们的网站、服务、数据库,甚至是云计算业务。

这就意味着,只有那些能够为用户提供平台层能力的工具,才会真正成为开发者们关心和愿意付费的产品。而Docker项目这样一个只能用来创建和启停容器的小工具,最终只能充当这些平台项目的“幕后英雄”。

而谈到Docker项目的定位问题,就不得不说说Docker公司的老朋友和老对手CoreOS了。

CoreOS是一个基础设施领域创业公司。 它的核心产品是一个定制化的操作系统,用户可以按照分布式集群的方式,管理所有安装了这个操作系统的节点。从而,用户在集群里部署和管理应用就像使用单机一样方便了。

Docker项目发布后,CoreOS公司很快就认识到可以把“容器”的概念无缝集成到自己的这套方案中,从而为用户提供更高层次的PaaS能力。所以,CoreOS很早就成了Docker项目的贡献者,并在短时间内成为了Docker项目中第二重要的力量。

然而,这段短暂的蜜月期到2014年底就草草结束了。CoreOS公司以强烈的措辞宣布与Docker公司停止合作,并直接推出了自己研制的Rocket(后来叫rkt)容器。

这次决裂的根本原因,正是源于Docker公司对Docker项目定位的不满足。Docker公司解决这种不满足的方法就是,让Docker项目提供更多的平台层能力,即向PaaS项目进化。而这,显然与CoreOS公司的核心产品和战略发生了严重冲突。

也就是说,Docker公司在2014年就已经定好了平台化的发展方向,并且绝对不会跟CoreOS在平台层面开展任何合作。这样看来,Docker公司在2014年12月的DockerCon上发布Swarm的举动,也就一点都不突然了。

相较于CoreOS是依托于一系列开源项目(比如Container Linux操作系统、Fleet作业调度工具、systemd进程管理和rkt容器),一层层搭建起来的平台产品,Swarm项目则是以一个完整的整体来对外提供集群管理功能。而Swarm的最大亮点,则是它完全使用Docker项目原本的容器管理API来完成集群管理,比如:

  • 单机Docker项目:
$ docker run "我的容器
  • 多机Docker项目:
$ docker run -H "我的Swarm集群API地址" "我的容器"

所以在部署了Swarm的多机环境下,用户只需要使用原先的Docker指令创建一个容器,这个请求就会被Swarm拦截下来处理,然后通过具体的调度算法找到一个合适的Docker Daemon运行起来。

这个操作方式简洁明了,对于已经了解过Docker命令行的开发者们也很容易掌握。所以,这样一个“原生”的Docker容器集群管理项目一经发布,就受到了已有Docker用户群的热捧。而相比之下,CoreOS的解决方案就显得非常另类,更不用说用户还要去接受完全让人摸不着头脑、新造的容器项目rkt了。

当然,Swarm项目只是Docker公司重新定义“PaaS”的关键一环而已。在2014年到2015年这段时间里,Docker项目的迅速走红催生出了一个非常繁荣的“Docker生态”。在这个生态里,围绕着Docker在各个层次进行集成和创新的项目层出不穷。

而此时已经大红大紫到“不差钱”的Docker公司,开始及时地借助这波浪潮通过并购来完善自己的平台层能力。其中一个最成功的案例,莫过于对Fig项目的收购。

要知道,Fig项目基本上只是靠两个人全职开发和维护的,可它却是当时GitHub上热度堪比Docker项目的明星。

Fig项目之所以受欢迎,在于它在开发者面前第一次提出了“容器编排”(Container Orchestration)的概念。

其实,“编排”(Orchestration)在云计算行业里不算是新词汇,它主要是指用户如何通过某些工具或者配置来完成一组虚拟机以及关联资源的定义、配置、创建、删除等工作,然后由云计算平台按照这些指定的逻辑来完成的过程。

而容器时代,“编排”显然就是对Docker容器的一系列定义、配置和创建动作的管理。而Fig的工作实际上非常简单:假如现在用户需要部署的是应用容器A、数据库容器B、负载均衡容器C,那么Fig就允许用户把A、B、C三个容器定义在一个配置文件中,并且可以指定它们之间的关联关系,比如容器A需要访问数据库容器B。

接下来,你只需要执行一条非常简单的指令:

$ fig up

Fig就会把这些容器的定义和配置交给Docker API按照访问逻辑依次创建,你的一系列容器就都启动了;而容器A与B之间的关联关系,也会交给Docker的Link功能通过写入hosts文件的方式进行配置。更重要的是,你还可以在Fig的配置文件里定义各种容器的副本个数等编排参数,再加上Swarm的集群管理能力,一个活脱脱的PaaS呼之欲出。

Fig项目被收购后改名为Compose,它成了Docker公司到目前为止第二大受欢迎的项目,一直到今天也依然被很多人使用。

当时的这个容器生态里,还有很多令人眼前一亮的开源项目或公司。比如,专门负责处理容器网络的SocketPlane项目(后来被Docker公司收购),专门负责处理容器存储的Flocker项目(后来被EMC公司收购),专门给Docker集群做图形化管理界面和对外提供云服务的Tutum项目(后来被Docker公司收购)等等。

一时之间,整个后端和云计算领域的聪明才俊都汇集在了这个“小鲸鱼”的周围,为Docker生态的蓬勃发展献上了自己的智慧。

而除了这个异常繁荣的、围绕着Docker项目和公司的生态之外,还有一个势力在当时也是风头无两,这就是老牌集群管理项目Mesos和它背后的创业公司Mesosphere。

Mesos作为Berkeley主导的大数据套件之一,是大数据火热时最受欢迎的资源管理项目,也是跟Yarn项目杀得难舍难分的实力派选手。

不过,大数据所关注的计算密集型离线业务,其实并不像常规的Web服务那样适合用容器进行托管和扩容,也没有对应用打包的强烈需求,所以Hadoop、Spark等项目到现在也没在容器技术上投下更大的赌注;但是对于Mesos来说,天生的两层调度机制让它非常容易从大数据领域抽身,转而去支持受众更加广泛的PaaS业务。

在这种思路的指导下,Mesosphere公司发布了一个名为Marathon的项目,而这个项目很快就成为了Docker Swarm的一个有力竞争对手。

虽然不能提供像Swarm那样的原生Docker API,Mesos社区却拥有一个独特的竞争力:超大规模集群的管理经验。

早在几年前,Mesos就已经通过了万台节点的验证,2014年之后又被广泛使用在eBay等大型互联网公司的生产环境中。而这次通过Marathon实现了诸如应用托管和负载均衡的PaaS功能之后,Mesos+Marathon的组合实际上进化成了一个高度成熟的PaaS项目,同时还能很好地支持大数据业务。

所以,在这波容器化浪潮中,Mesosphere公司不失时机地提出了一个名叫“DC/OS”(数据中心操作系统)的口号和产品,旨在使用户能够像管理一台机器那样管理一个万级别的物理机集群,并且使用Docker容器在这个集群里自由地部署应用。而这,对很多大型企业来说具有着非同寻常的吸引力。

这时,如果你再去审视当时的容器技术生态,就不难发现CoreOS公司竟然显得有些尴尬了。它的rkt容器完全打不开局面,Fleet集群管理项目更是少有人问津,CoreOS完全被Docker公司压制了。

而处境同样不容乐观的似乎还有RedHat,作为Docker项目早期的重要贡献者,RedHat也是因为对Docker公司平台化战略不满而愤愤退出。但此时,它竟只剩下OpenShift这个跟Cloud Foundry同时代的经典PaaS一张牌可以打,跟Docker Swarm和转型后的Mesos完全不在同一个“竞技水平”之上。

那么,事实果真如此吗?

2014年注定是一个神奇的年份。就在这一年的6月,基础设施领域的翘楚Google公司突然发力,正式宣告了一个名叫Kubernetes项目的诞生。而这个项目,不仅挽救了当时的CoreOS和RedHat,还如同当年Docker项目的横空出世一样,再一次改变了整个容器市场的格局。

总结

我分享了Docker公司平台化战略的来龙去脉,阐述了Docker Swarm项目发布的意义和它背后的设计思想,介绍了Fig(后来的Compose)项目如何成为了继Docker之后最受瞩目的新星。

同时,我也和你一起回顾了2014~2015年间如火如荼的容器化浪潮里群雄并起的繁荣姿态。在这次生态大爆发中,Docker公司和Mesosphere公司,依托自身优势率先占据了有利位置。

但是,更强大的挑战者们,即将在不久后纷至沓来。

思考题

你所在团队有没有在2014~2015年Docker热潮中,推出过相关的容器产品或者项目?现在结局如何呢?

欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

04-预习篇·小鲸鱼大事记(四):尘埃落定

你好,我是张磊。我今天分享的主题是:小鲸鱼大事记之尘埃落定。

在上一次的分享中我提到,伴随着Docker公司一手打造出来的容器技术生态在云计算市场中站稳了脚跟,围绕着Docker项目进行的各个层次的集成与创新产品,也如雨后春笋般出现在这个新兴市场当中。而Docker公司,不失时机地发布了Docker Compose、Swarm和Machine“三件套”,在重新定义PaaS的方向上走出了最关键的一步。

这段时间,也正是Docker生态创业公司们的春天,大量围绕着Docker项目的网络、存储、监控、CI/CD,甚至UI项目纷纷出台,也涌现出了很多Rancher、Tutum这样在开源与商业上均取得了巨大成功的创业公司。

在2014~2015年间,整个容器社区可谓热闹非凡。

这令人兴奋的繁荣背后,却浮现出了更多的担忧。这其中最主要的负面情绪,是对Docker公司商业化战略的种种顾虑。

事实上,很多从业者也都看得明白,Docker项目此时已经成为Docker公司一个商业产品。而开源,只是Docker公司吸引开发者群体的一个重要手段。不过这么多年来,开源社区的商业化其实都是类似的思路,无非是高不高调、心不心急的问题罢了。

而真正令大多数人不满意的是,Docker公司在Docker开源项目的发展上,始终保持着绝对的权威和发言权,并在多个场合用实际行动挑战到了其他玩家(比如,CoreOS、RedHat,甚至谷歌和微软)的切身利益。

那么,这个时候,大家的不满也就不再是在GitHub上发发牢骚这么简单了。

相信很多容器领域的老玩家们都听说过,Docker项目刚刚兴起时,Google也开源了一个在内部使用多年、经历过生产环境验证的Linux容器:lmctfy(Let Me Container That For You)。

然而,面对Docker项目的强势崛起,这个对用户没那么友好的Google容器项目根本没有招架之力。所以,知难而退的Google公司,向Docker公司表示了合作的愿望:关停这个项目,和Docker公司共同推进一个中立的容器运行时(container runtime)库作为Docker项目的核心依赖。

不过,Docker公司并没有认同这个明显会削弱自己地位的提议,还在不久后,自己发布了一个容器运行时库Libcontainer。这次匆忙的、由一家主导的、并带有战略性考量的重构,成了Libcontainer被社区长期诟病代码可读性差、可维护性不强的一个重要原因。

至此,Docker公司在容器运行时层面上的强硬态度,以及Docker项目在高速迭代中表现出来的不稳定和频繁变更的问题,开始让社区叫苦不迭。

这种情绪在2015年达到了一个小高潮,容器领域的其他几位玩家开始商议“切割”Docker项目的话语权。而“切割”的手段也非常经典,那就是成立一个中立的基金会。

于是,2015年6月22日,由Docker公司牵头,CoreOS、Google、RedHat等公司共同宣布,Docker公司将Libcontainer捐出,并改名为RunC项目,交由一个完全中立的基金会管理,然后以RunC为依据,大家共同制定一套容器和镜像的标准和规范。

这套标准和规范,就是OCI( Open Container Initiative )。OCI的提出,意在将容器运行时和镜像的实现从Docker项目中完全剥离出来。这样做,一方面可以改善Docker公司在容器技术上一家独大的现状,另一方面也为其他玩家不依赖于Docker项目构建各自的平台层能力提供了可能。

不过,不难看出,OCI的成立更多的是这些容器玩家出于自身利益进行干涉的一个妥协结果。所以,尽管Docker是OCI的发起者和创始成员,它却很少在OCI的技术推进和标准制定等事务上扮演关键角色,也没有动力去积极地推进这些所谓的标准。

这,也正是迄今为止OCI组织效率持续低下的根本原因。

眼看着OCI并没能改变Docker公司在容器领域一家独大的现状,Google和RedHat等公司于是把与第二把武器摆上了台面。

Docker之所以不担心OCI的威胁,原因就在于它的Docker项目是容器生态的事实标准,而它所维护的Docker社区也足够庞大。可是,一旦这场斗争被转移到容器之上的平台层,或者说PaaS层,Docker公司的竞争优势便立刻捉襟见肘了。

在这个领域里,像Google和RedHat这样的成熟公司,都拥有着深厚的技术积累;而像CoreOS这样的创业公司,也拥有像Etcd这样被广泛使用的开源基础设施项目。

可是Docker公司呢?它却只有一个Swarm。

所以这次,Google、RedHat等开源基础设施领域玩家们,共同牵头发起了一个名为CNCF(Cloud Native Computing Foundation)的基金会。这个基金会的目的其实很容易理解:它希望,以Kubernetes项目为基础,建立一个由开源基础设施领域厂商主导的、按照独立基金会方式运营的平台级社区,来对抗以Docker公司为核心的容器商业生态。

而为了打造出这样一条围绕Kubernetes项目的“护城河”,CNCF社区就需要至少确保两件事情:

  1. Kubernetes项目必须能够在容器编排领域取得足够大的竞争优势;

  2. CNCF社区必须以Kubernetes项目为核心,覆盖足够多的场景。

我们先来看看CNCF社区如何解决Kubernetes项目在编排领域的竞争力的问题。

在容器编排领域,Kubernetes项目需要面对来自Docker公司和Mesos社区两个方向的压力。不难看出,Swarm和Mesos实际上分别从两个不同的方向讲出了自己最擅长的故事:Swarm擅长的是跟Docker生态的无缝集成,而Mesos擅长的则是大规模集群的调度与管理。

这两个方向,也是大多数人做容器集群管理项目时最容易想到的两个出发点。也正因为如此,Kubernetes项目如果继续在这两个方向上做文章恐怕就不太明智了。

所以这一次,Kubernetes选择的应对方式是:Borg。

如果你看过Kubernetes项目早期的GitHub Issue和Feature的话,就会发现它们大多来自于Borg和Omega系统的内部特性,这些特性落到Kubernetes项目上,就是Pod、Sidecar等功能和设计模式。

这就解释了,为什么Kubernetes发布后,很多人“抱怨”其设计思想过于“超前”的原因:Kubernetes项目的基础特性,并不是几个工程师突然“拍脑袋”想出来的东西,而是Google公司在容器化基础设施领域多年来实践经验的沉淀与升华。这,正是Kubernetes项目能够从一开始就避免同Swarm和Mesos社区同质化的重要手段。

于是,CNCF接下来的任务就是,如何把这些先进的思想通过技术手段在开源社区落地,并培育出一个认同这些理念的生态?这时,RedHat就发挥了重要作用。

当时,Kubernetes团队规模很小,能够投入的工程能力也十分紧张,而这恰恰是RedHat的长处。更难得的是,RedHat是世界上为数不多的、能真正理解开源社区运作和项目研发真谛的合作伙伴。

所以,RedHat与Google联盟的成立,不仅保证了RedHat在Kubernetes项目上的影响力,也正式开启了容器编排领域“三国鼎立”的局面。

这时,我们再重新审视容器生态的格局,就不难发现Kubernetes项目、Docker公司和Mesos社区这三大玩家的关系已经发生了微妙的变化。

其中,Mesos社区与容器技术的关系,更像是“借势”,而不是这个领域真正的参与者和领导者。这个事实,加上它所属的Apache社区固有的封闭性,导致了Mesos社区虽然技术最为成熟,却在容器编排领域鲜有创新。

这也是为何,Google公司很快就把注意力转向了动作更加激进的Docker公司。

有意思的是,Docker公司对Mesos社区也是类似的看法。所以从一开始,Docker公司就把应对Kubernetes项目的竞争摆在了首要位置:一方面,不断强调“Docker Native”的“重要性”,另一方面,与Kubernetes项目在多个场合进行了直接的碰撞。

不过,这次竞争的发展态势,很快就超过了Docker公司的预期。

Kubernetes项目并没有跟Swarm项目展开同质化的竞争,所以“Docker Native”的说辞并没有太大的杀伤力。相反地,Kubernetes项目让人耳目一新的设计理念和号召力,很快就构建出了一个与众不同的容器编排与管理的生态。

就这样,Kubernetes项目在GitHub上的各项指标开始一骑绝尘,将Swarm项目远远地甩在了身后。

有了这个基础,CNCF社区就可以放心地解决第二个问题了。

在已经囊括了容器监控事实标准的Prometheus项目之后,CNCF社区迅速在成员项目中添加了Fluentd、OpenTracing、CNI等一系列容器生态的知名工具和项目。

而在看到了CNCF社区对用户表现出来的巨大吸引力之后,大量的公司和创业团队也开始专门针对CNCF社区而非Docker公司制定推广策略。

面对这样的竞争态势,Docker公司决定更进一步。在2016年,Docker公司宣布了一个震惊所有人的计划:放弃现有的Swarm项目,将容器编排和集群管理功能全部内置到Docker项目当中。

显然,Docker公司意识到了Swarm项目目前唯一的竞争优势,就是跟Docker项目的无缝集成。那么,如何让这种优势最大化呢?那就是把Swarm内置到Docker项目当中。

实际上,从工程角度来看,这种做法的风险很大。内置容器编排、集群管理和负载均衡能力,固然可以使得Docker项目的边界直接扩大到一个完整的PaaS项目的范畴,但这种变更带来的技术复杂度和维护难度,长远来看对Docker项目是不利的。

不过,在当时的大环境下,Docker公司的选择恐怕也带有一丝孤注一掷的意味。

Kubernetes的应对策略则是反其道而行之,开始在整个社区推进“民主化”架构,即:从API到容器运行时的每一层,Kubernetes项目都为开发者暴露出了可以扩展的插件机制,鼓励用户通过代码的方式介入Kubernetes项目的每一个阶段。

Kubernetes项目的这个变革的效果立竿见影,很快在整个容器社区中催生出了大量的、基于Kubernetes API和扩展接口的二次创新工作,比如:

  • 目前热度极高的微服务治理项目Istio;
  • 被广泛采用的有状态应用部署框架Operator;
  • 还有像Rook这样的开源创业项目,它通过Kubernetes的可扩展接口,把Ceph这样的重量级产品封装成了简单易用的容器存储插件。

就这样,在这种鼓励二次创新的整体氛围当中,Kubernetes社区在2016年之后得到了空前的发展。更重要的是,不同于之前局限于“打包、发布”这样的PaaS化路线,这一次容器社区的繁荣,是一次完全以Kubernetes项目为核心的“百家争鸣”

面对Kubernetes社区的崛起和壮大,Docker公司也不得不面对自己豪赌失败的现实。但在早前拒绝了微软的天价收购之后,Docker公司实际上已经没有什么回旋余地,只能选择逐步放弃开源社区而专注于自己的商业化转型。

所以,从2017年开始,Docker公司先是将Docker项目的容器运行时部分Containerd捐赠给CNCF社区,标志着Docker项目已经全面升级成为一个PaaS平台;紧接着,Docker公司宣布将Docker项目改名为Moby,然后交给社区自行维护,而Docker公司的商业产品将占有Docker这个注册商标。

Docker公司这些举措背后的含义非常明确:它将全面放弃在开源社区同Kubernetes生态的竞争,转而专注于自己的商业业务,并且通过将Docker项目改名为Moby的举动,将原本属于Docker社区的用户转化成了自己的客户。

2017年10月,Docker公司出人意料地宣布,将在自己的主打产品Docker企业版中内置Kubernetes项目,这标志着持续了近两年之久的“编排之争”至此落下帷幕。

2018年1月30日,RedHat宣布斥资2.5亿美元收购CoreOS。

2018年3月28日,这一切纷争的始作俑者,Docker公司的CTO Solomon Hykes宣布辞职,曾经纷纷扰扰的容器技术圈子,到此尘埃落定。

总结

容器技术圈子在短短几年里发生了很多变数,但很多事情其实也都在情理之中。就像Docker这样一家创业公司,在通过开源社区的运作取得了巨大的成功之后,就不得不面对来自整个云计算产业的竞争和围剿。而这个产业的垄断特性,对于Docker这样的技术型创业公司其实天生就不友好。

在这种局势下,接受微软的天价收购,在大多数人看来都是一个非常明智和实际的选择。可是Solomon Hykes却多少带有一些理想主义的影子,既然不甘于“寄人篱下”,那他就必须带领Docker公司去对抗来自整个云计算产业的压力。

只不过,Docker公司最后选择的对抗方式,是将开源项目与商业产品紧密绑定,打造了一个极端封闭的技术生态。而这,其实违背了Docker项目与开发者保持亲密关系的初衷。相比之下,Kubernetes社区,正是以一种更加温和的方式,承接了Docker项目的未尽事业,即:以开发者为核心,构建一个相对民主和开放的容器生态。

这也是为何,Kubernetes项目的成功其实是必然的。

现在,我们很难想象如果Docker公司最初选择了跟Kubernetes社区合作,如今的容器生态又将会是怎样的一番景象。不过我们可以肯定的是,Docker公司在过去五年里的风云变幻,以及Solomon Hykes本人的传奇经历,都已经在云计算的长河中留下了浓墨重彩的一笔。

思考题

你如何评价Solomon Hykes在Docker公司发展历程中的所作所为?你又是否看好Docker公司在今后的发展呢?

欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

05-白话容器基础(一):从进程说开去

你好,我是张磊。今天我和你分享的主题是:白话容器基础之从进程说开去。

在前面的4篇预习文章中,我梳理了“容器”这项技术的来龙去脉,通过这些内容,我希望你能理解如下几个事实:

  • 容器技术的兴起源于PaaS技术的普及;
  • Docker公司发布的Docker项目具有里程碑式的意义;
  • Docker项目通过“容器镜像”,解决了应用打包这个根本性难题。

紧接着,我详细介绍了容器技术圈在过去五年里的“风云变幻”,而通过这部分内容,我希望你能理解这样一个道理:

容器本身没有价值,有价值的是“容器编排”。

也正因为如此,容器技术生态才爆发了一场关于“容器编排”的“战争”。而这次战争,最终以Kubernetes项目和CNCF社区的胜利而告终。所以,这个专栏后面的内容,我会以Docker和Kubernetes项目为核心,为你详细介绍容器技术的各项实践与其中的原理。

不过在此之前,你还需要搞清楚一个更为基础的问题:

容器,到底是怎么一回事儿?

在第一篇预习文章《小鲸鱼大事记(一):初出茅庐》中,我已经提到过,容器其实是一种沙盒技术。顾名思义,沙盒就是能够像一个集装箱一样,把你的应用“装”起来的技术。这样,应用与应用之间,就因为有了边界而不至于相互干扰;而被装进集装箱的应用,也可以被方便地搬来搬去,这不就是PaaS最理想的状态嘛。

不过,这两个能力说起来简单,但要用技术手段去实现它们,可能大多数人就无从下手了。

所以,我就先来跟你说说这个“边界”的实现手段。

假如,现在你要写一个计算加法的小程序,这个程序需要的输入来自于一个文件,计算完成后的结果则输出到另一个文件中。

由于计算机只认识0和1,所以无论用哪种语言编写这段代码,最后都需要通过某种方式翻译成二进制文件,才能在计算机操作系统中运行起来。

而为了能够让这些代码正常运行,我们往往还要给它提供数据,比如我们这个加法程序所需要的输入文件。这些数据加上代码本身的二进制文件,放在磁盘上,就是我们平常所说的一个“程序”,也叫代码的可执行镜像(executable image)。

然后,我们就可以在计算机上运行这个“程序”了。

首先,操作系统从“程序”中发现输入数据保存在一个文件中,所以这些数据就会被加载到内存中待命。同时,操作系统又读取到了计算加法的指令,这时,它就需要指示CPU完成加法操作。而CPU与内存协作进行加法计算,又会使用寄存器存放数值、内存堆栈保存执行的命令和变量。同时,计算机里还有被打开的文件,以及各种各样的I/O设备在不断地调用中修改自己的状态。

就这样,一旦“程序”被执行起来,它就从磁盘上的二进制文件,变成了计算机内存中的数据、寄存器里的值、堆栈中的指令、被打开的文件,以及各种设备的状态信息的一个集合。像这样一个程序运行起来后的计算机执行环境的总和,就是我们今天的主角:进程。

所以,对于进程来说,它的静态表现就是程序,平常都安安静静地待在磁盘上;而一旦运行起来,它就变成了计算机里的数据和状态的总和,这就是它的动态表现。

容器技术的核心功能,就是通过约束和修改进程的动态表现,从而为其创造出一个“边界”。

对于Docker等大多数Linux容器来说,Cgroups技术是用来制造约束的主要手段,而Namespace技术则是用来修改进程视图的主要方法。

你可能会觉得Cgroups和Namespace这两个概念很抽象,别担心,接下来我们一起动手实践一下,你就很容易理解这两项技术了。

假设你已经有了一个Linux操作系统上的Docker项目在运行,比如我的环境是Ubuntu 16.04和Docker CE 18.05。

接下来,让我们首先创建一个容器来试试。

$ docker run -it busybox /bin/sh
/ #

这个命令是Docker项目最重要的一个操作,即大名鼎鼎的docker run。

而-it参数告诉了Docker项目在启动容器后,需要给我们分配一个文本输入/输出环境,也就是TTY,跟容器的标准输入相关联,这样我们就可以和这个Docker容器进行交互了。而/bin/sh就是我们要在Docker容器里运行的程序。

所以,上面这条指令翻译成人类的语言就是:请帮我启动一个容器,在容器里执行/bin/sh,并且给我分配一个命令行终端跟这个容器交互。

这样,我的Ubuntu 16.04机器就变成了一个宿主机,而一个运行着/bin/sh的容器,就跑在了这个宿主机里面。

上面的例子和原理,如果你已经玩过Docker,一定不会感到陌生。此时,如果我们在容器里执行一下ps指令,就会发现一些更有趣的事情:

/ # ps
PID  USER   TIME COMMAND
  1 root   0:00 /bin/sh
  10 root   0:00 ps

可以看到,我们在Docker里最开始执行的/bin/sh,就是这个容器内部的第1号进程(PID=1),而这个容器里一共只有两个进程在运行。这就意味着,前面执行的/bin/sh,以及我们刚刚执行的ps,已经被Docker隔离在了一个跟宿主机完全不同的世界当中。

这究竟是怎么做到的呢?

本来,每当我们在宿主机上运行了一个/bin/sh程序,操作系统都会给它分配一个进程编号,比如PID=100。这个编号是进程的唯一标识,就像员工的工牌一样。所以PID=100,可以粗略地理解为这个/bin/sh是我们公司里的第100号员工,而第1号员工就自然是比尔 · 盖茨这样统领全局的人物。

而现在,我们要通过Docker把这个/bin/sh程序运行在一个容器当中。这时候,Docker就会在这个第100号员工入职时给他施一个“障眼法”,让他永远看不到前面的其他99个员工,更看不到比尔 · 盖茨。这样,他就会错误地以为自己就是公司里的第1号员工。

这种机制,其实就是对被隔离应用的进程空间做了手脚,使得这些进程只能看到重新计算过的进程编号,比如PID=1。可实际上,他们在宿主机的操作系统里,还是原来的第100号进程。

这种技术,就是Linux里面的Namespace机制。而Namespace的使用方式也非常有意思:它其实只是Linux创建新进程的一个可选参数。我们知道,在Linux系统中创建进程的系统调用是clone(),比如:

int pid = clone(main_function, stack_size, SIGCHLD, NULL);

这个系统调用就会为我们创建一个新的进程,并且返回它的进程号pid。

而当我们用clone()系统调用创建一个新进程时,就可以在参数中指定CLONE_NEWPID参数,比如:

int pid = clone(main_function, stack_size, CLONE_NEWPID | SIGCHLD, NULL);

这时,新创建的这个进程将会“看到”一个全新的进程空间,在这个进程空间里,它的PID是1。之所以说“看到”,是因为这只是一个“障眼法”,在宿主机真实的进程空间里,这个进程的PID还是真实的数值,比如100。

当然,我们还可以多次执行上面的clone()调用,这样就会创建多个PID Namespace,而每个Namespace里的应用进程,都会认为自己是当前容器里的第1号进程,它们既看不到宿主机里真正的进程空间,也看不到其他PID Namespace里的具体情况。

除了我们刚刚用到的PID Namespace,Linux操作系统还提供了Mount、UTS、IPC、Network和User这些Namespace,用来对各种不同的进程上下文进行“障眼法”操作。

比如,Mount Namespace,用于让被隔离进程只看到当前Namespace里的挂载点信息;Network Namespace,用于让被隔离进程看到当前Namespace里的网络设备和配置。

这,就是Linux容器最基本的实现原理了。

所以,Docker容器这个听起来玄而又玄的概念,实际上是在创建容器进程时,指定了这个进程所需要启用的一组Namespace参数。这样,容器就只能“看”到当前Namespace所限定的资源、文件、设备、状态,或者配置。而对于宿主机以及其他不相关的程序,它就完全看不到了。

所以说,容器,其实是一种特殊的进程而已。

总结

谈到为“进程划分一个独立空间”的思想,相信你一定会联想到虚拟机。而且,你应该还看过一张虚拟机和容器的对比图。

这幅图的左边,画出了虚拟机的工作原理。其中,名为Hypervisor的软件是虚拟机最主要的部分。它通过硬件虚拟化功能,模拟出了运行一个操作系统需要的各种硬件,比如CPU、内存、I/O设备等等。然后,它在这些虚拟的硬件上安装了一个新的操作系统,即Guest OS。

这样,用户的应用进程就可以运行在这个虚拟的机器中,它能看到的自然也只有Guest OS的文件和目录,以及这个机器里的虚拟设备。这就是为什么虚拟机也能起到将不同的应用进程相互隔离的作用。

而这幅图的右边,则用一个名为Docker Engine的软件替换了Hypervisor。这也是为什么,很多人会把Docker项目称为“轻量级”虚拟化技术的原因,实际上就是把虚拟机的概念套在了容器上。

可是这样的说法,却并不严谨。

在理解了Namespace的工作方式之后,你就会明白,跟真实存在的虚拟机不同,在使用Docker的时候,并没有一个真正的“Docker容器”运行在宿主机里面。Docker项目帮助用户启动的,还是原来的应用进程,只不过在创建这些进程时,Docker为它们加上了各种各样的Namespace参数。

这时,这些进程就会觉得自己是各自PID Namespace里的第1号进程,只能看到各自Mount Namespace里挂载的目录和文件,只能访问到各自Network Namespace里的网络设备,就仿佛运行在一个个“容器”里面,与世隔绝。

不过,相信你此刻已经会心一笑:这些不过都是“障眼法”罢了。

思考题

  1. 鉴于我对容器本质的讲解,你觉得上面这张容器和虚拟机对比图右侧关于容器的部分,怎么画才更精确?

  2. 你是否知道最新的Docker项目默认会为容器启用哪些Namespace吗?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

06-白话容器基础(二):隔离与限制

你好,我是张磊。我今天和你分享的主题是:白话容器基础之隔离与限制。

在上一篇文章中,我详细介绍了Linux容器中用来实现“隔离”的技术手段:Namespace。而通过这些讲解,你应该能够明白,Namespace技术实际上修改了应用进程看待整个计算机“视图”,即它的“视线”被操作系统做了限制,只能“看到”某些指定的内容。但对于宿主机来说,这些被“隔离”了的进程跟其他进程并没有太大区别。

说到这一点,相信你也能够知道我在上一篇文章最后给你留下的第一个思考题的答案了:在之前虚拟机与容器技术的对比图里,不应该把Docker Engine或者任何容器管理工具放在跟Hypervisor相同的位置,因为它们并不像Hypervisor那样对应用进程的隔离环境负责,也不会创建任何实体的“容器”,真正对隔离环境负责的是宿主机操作系统本身:

所以,在这个对比图里,我们应该把Docker画在跟应用同级别并且靠边的位置。这意味着,用户运行在容器里的应用进程,跟宿主机上的其他进程一样,都由宿主机操作系统统一管理,只不过这些被隔离的进程拥有额外设置过的Namespace参数。而Docker项目在这里扮演的角色,更多的是旁路式的辅助和管理工作。

我在后续分享CRI和容器运行时的时候还会专门介绍,其实像Docker这样的角色甚至可以去掉。

这样的架构也解释了为什么Docker项目比虚拟机更受欢迎的原因。

这是因为,使用虚拟化技术作为应用沙盒,就必须要由Hypervisor来负责创建虚拟机,这个虚拟机是真实存在的,并且它里面必须运行一个完整的Guest OS才能执行用户的应用进程。这就不可避免地带来了额外的资源消耗和占用。

根据实验,一个运行着CentOS的KVM虚拟机启动后,在不做优化的情况下,虚拟机自己就需要占用100~200 MB内存。此外,用户应用运行在虚拟机里面,它对宿主机操作系统的调用就不可避免地要经过虚拟化软件的拦截和处理,这本身又是一层性能损耗,尤其对计算资源、网络和磁盘I/O的损耗非常大。

而相比之下,容器化后的用户应用,却依然还是一个宿主机上的普通进程,这就意味着这些因为虚拟化而带来的性能损耗都是不存在的;而另一方面,使用Namespace作为隔离手段的容器并不需要单独的Guest OS,这就使得容器额外的资源占用几乎可以忽略不计。

所以说,“敏捷”和“高性能”是容器相较于虚拟机最大的优势,也是它能够在PaaS这种更细粒度的资源管理平台上大行其道的重要原因。

不过,有利就有弊,基于Linux Namespace的隔离机制相比于虚拟化技术也有很多不足之处,其中最主要的问题就是:隔离得不彻底。

首先,既然容器只是运行在宿主机上的一种特殊的进程,那么多个容器之间使用的就还是同一个宿主机的操作系统内核。

尽管你可以在容器里通过Mount Namespace单独挂载其他不同版本的操作系统文件,比如CentOS或者Ubuntu,但这并不能改变共享宿主机内核的事实。这意味着,如果你要在Windows宿主机上运行Linux容器,或者在低版本的Linux宿主机上运行高版本的Linux容器,都是行不通的。

而相比之下,拥有硬件虚拟化技术和独立Guest OS的虚拟机就要方便得多了。最极端的例子是,Microsoft的云计算平台Azure,实际上就是运行在Windows服务器集群上的,但这并不妨碍你在它上面创建各种Linux虚拟机出来。

其次,在Linux内核中,有很多资源和对象是不能被Namespace化的,最典型的例子就是:时间。

这就意味着,如果你的容器中的程序使用settimeofday(2)系统调用修改了时间,整个宿主机的时间都会被随之修改,这显然不符合用户的预期。相比于在虚拟机里面可以随便折腾的自由度,在容器里部署应用的时候,“什么能做,什么不能做”,就是用户必须考虑的一个问题。

此外,由于上述问题,尤其是共享宿主机内核的事实,容器给应用暴露出来的攻击面是相当大的,应用“越狱”的难度自然也比虚拟机低得多。

更为棘手的是,尽管在实践中我们确实可以使用Seccomp等技术,对容器内部发起的所有系统调用进行过滤和甄别来进行安全加固,但这种方法因为多了一层对系统调用的过滤,必然会拖累容器的性能。何况,默认情况下,谁也不知道到底该开启哪些系统调用,禁止哪些系统调用。

所以,在生产环境中,没有人敢把运行在物理机上的Linux容器直接暴露到公网上。当然,我后续会讲到的基于虚拟化或者独立内核技术的容器实现,则可以比较好地在隔离与性能之间做出平衡。

在介绍完容器的“隔离”技术之后,我们再来研究一下容器的“限制”问题。

也许你会好奇,我们不是已经通过Linux Namespace创建了一个“容器”吗,为什么还需要对容器做“限制”呢?

我还是以PID Namespace为例,来给你解释这个问题。

虽然容器内的第1号进程在“障眼法”的干扰下只能看到容器里的情况,但是宿主机上,它作为第100号进程与其他所有进程之间依然是平等的竞争关系。这就意味着,虽然第100号进程表面上被隔离了起来,但是它所能够使用到的资源(比如CPU、内存),却是可以随时被宿主机上的其他进程(或者其他容器)占用的。当然,这个100号进程自己也可能把所有资源吃光。这些情况,显然都不是一个“沙盒”应该表现出来的合理行为。

Linux Cgroups就是Linux内核中用来为进程设置资源限制的一个重要功能。

有意思的是,Google的工程师在2006年发起这项特性的时候,曾将它命名为“进程容器”(process container)。实际上,在Google内部,“容器”这个术语长期以来都被用于形容被Cgroups限制过的进程组。后来Google的工程师们说,他们的KVM虚拟机也运行在Borg所管理的“容器”里,其实也是运行在Cgroups“容器”当中。这和我们今天说的Docker容器差别很大。

Linux Cgroups的全称是Linux Control Group。它最主要的作用,就是限制一个进程组能够使用的资源上限,包括CPU、内存、磁盘、网络带宽等等。

此外,Cgroups还能够对进程进行优先级设置、审计,以及将进程挂起和恢复等操作。在今天的分享中,我只和你重点探讨它与容器关系最紧密的“限制”能力,并通过一组实践来带你认识一下Cgroups。

在Linux中,Cgroups给用户暴露出来的操作接口是文件系统,即它以文件和目录的方式组织在操作系统的/sys/fs/cgroup路径下。在Ubuntu 16.04机器里,我可以用mount指令把它们展示出来,这条命令是:

$ mount -t cgroup
cpuset on /sys/fs/cgroup/cpuset type cgroup (rw,nosuid,nodev,noexec,relatime,cpuset)
cpu on /sys/fs/cgroup/cpu type cgroup (rw,nosuid,nodev,noexec,relatime,cpu)
cpuacct on /sys/fs/cgroup/cpuacct type cgroup (rw,nosuid,nodev,noexec,relatime,cpuacct)
blkio on /sys/fs/cgroup/blkio type cgroup (rw,nosuid,nodev,noexec,relatime,blkio)
memory on /sys/fs/cgroup/memory type cgroup (rw,nosuid,nodev,noexec,relatime,memory)
...

它的输出结果,是一系列文件系统目录。如果你在自己的机器上没有看到这些目录,那你就需要自己去挂载Cgroups,具体做法可以自行Google。

可以看到,在/sys/fs/cgroup下面有很多诸如cpuset、cpu、 memory这样的子目录,也叫子系统。这些都是我这台机器当前可以被Cgroups进行限制的资源种类。而在子系统对应的资源种类下,你就可以看到该类资源具体可以被限制的方法。比如,对CPU子系统来说,我们就可以看到如下几个配置文件,这个指令是:

$ ls /sys/fs/cgroup/cpu
cgroup.clone_children cpu.cfs_period_us cpu.rt_period_us  cpu.shares notify_on_release
cgroup.procs      cpu.cfs_quota_us  cpu.rt_runtime_us cpu.stat  tasks

如果熟悉Linux CPU管理的话,你就会在它的输出里注意到cfs_period和cfs_quota这样的关键词。这两个参数需要组合使用,可以用来限制进程在长度为cfs_period的一段时间内,只能被分配到总量为cfs_quota的CPU时间。

而这样的配置文件又如何使用呢?

你需要在对应的子系统下面创建一个目录,比如,我们现在进入/sys/fs/cgroup/cpu目录下:

root@ubuntu:/sys/fs/cgroup/cpu$ mkdir container
root@ubuntu:/sys/fs/cgroup/cpu$ ls container/
cgroup.clone_children cpu.cfs_period_us cpu.rt_period_us  cpu.shares notify_on_release
cgroup.procs      cpu.cfs_quota_us  cpu.rt_runtime_us cpu.stat  tasks

这个目录就称为一个“控制组”。你会发现,操作系统会在你新创建的container目录下,自动生成该子系统对应的资源限制文件。

现在,我们在后台执行这样一条脚本:

$ while : ; do : ; done &
[1] 226

显然,它执行了一个死循环,可以把计算机的CPU吃到100%,根据它的输出,我们可以看到这个脚本在后台运行的进程号(PID)是226。

这样,我们可以用top指令来确认一下CPU有没有被打满:

$ top
%Cpu0 :100.0 us, 0.0 sy, 0.0 ni, 0.0 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st

在输出里可以看到,CPU的使用率已经100%了(%Cpu0 :100.0 us)。

而此时,我们可以通过查看container目录下的文件,看到container控制组里的CPU quota还没有任何限制(即:-1),CPU period则是默认的100 ms(100000 us):

$ cat /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us
-1
$ cat /sys/fs/cgroup/cpu/container/cpu.cfs_period_us
100000

接下来,我们可以通过修改这些文件的内容来设置限制。

比如,向container组里的cfs_quota文件写入20 ms(20000 us):

$ echo 20000 > /sys/fs/cgroup/cpu/container/cpu.cfs_quota_us

结合前面的介绍,你应该能明白这个操作的含义,它意味着在每100 ms的时间里,被该控制组限制的进程只能使用20 ms的CPU时间,也就是说这个进程只能使用到20%的CPU带宽。

接下来,我们把被限制的进程的PID写入container组里的tasks文件,上面的设置就会对该进程生效了:

$ echo 226 > /sys/fs/cgroup/cpu/container/tasks

我们可以用top指令查看一下:

$ top
%Cpu0 : 20.3 us, 0.0 sy, 0.0 ni, 79.7 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st

可以看到,计算机的CPU使用率立刻降到了20%(%Cpu0 : 20.3 us)。

除CPU子系统外,Cgroups的每一个子系统都有其独有的资源限制能力,比如:

  • blkio,为​​​块​​​设​​​备​​​设​​​定​​​I/O限​​​制,一般用于磁盘等设备;
  • cpuset,为进程分配单独的CPU核和对应的内存节点;
  • memory,为进程设定内存使用的限制。

Linux Cgroups的设计还是比较易用的,简单粗暴地理解呢,它就是一个子系统目录加上一组资源限制文件的组合。而对于Docker等Linux容器项目来说,它们只需要在每个子系统下面,为每个容器创建一个控制组(即创建一个新目录),然后在启动容器进程之后,把这个进程的PID填写到对应控制组的tasks文件中就可以了。

而至于在这些控制组下面的资源文件里填上什么值,就靠用户执行docker run时的参数指定了,比如这样一条命令:

$ docker run -it --cpu-period=100000 --cpu-quota=20000 ubuntu /bin/bash

在启动这个容器后,我们可以通过查看Cgroups文件系统下,CPU子系统中,“docker”这个控制组里的资源限制文件的内容来确认:

$ cat /sys/fs/cgroup/cpu/docker/5d5c9f67d/cpu.cfs_period_us
100000
$ cat /sys/fs/cgroup/cpu/docker/5d5c9f67d/cpu.cfs_quota_us
20000

这就意味着这个Docker容器,只能使用到20%的CPU带宽。

总结

在这篇文章中,我首先介绍了容器使用Linux Namespace作为隔离手段的优势和劣势,对比了Linux容器跟虚拟机技术的不同,进一步明确了“容器只是一种特殊的进程”这个结论。

除了创建Namespace之外,在后续关于容器网络的分享中,我还会介绍一些其他Namespace的操作,比如看不见摸不着的Linux Namespace在计算机中到底如何表示、一个进程如何“加入”到其他进程的Namespace当中,等等。

紧接着,我详细介绍了容器在做好了隔离工作之后,又如何通过Linux Cgroups实现资源的限制,并通过一系列简单的实验,模拟了Docker项目创建容器限制的过程。

通过以上讲述,你现在应该能够理解,一个正在运行的Docker容器,其实就是一个启用了多个Linux Namespace的应用进程,而这个进程能够使用的资源量,则受Cgroups配置的限制。

这也是容器技术中一个非常重要的概念,即:容器是一个“单进程”模型。

由于一个容器的本质就是一个进程,用户的应用进程实际上就是容器里PID=1的进程,也是其他后续创建的所有进程的父进程。这就意味着,在一个容器中,你没办法同时运行两个不同的应用,除非你能事先找到一个公共的PID=1的程序来充当两个不同应用的父进程,这也是为什么很多人都会用systemd或者supervisord这样的软件来代替应用本身作为容器的启动进程。

但是,在后面分享容器设计模式时,我还会推荐其他更好的解决办法。这是因为容器本身的设计,就是希望容器和应用能够同生命周期,这个概念对后续的容器编排非常重要。否则,一旦出现类似于“容器是正常运行的,但是里面的应用早已经挂了”的情况,编排系统处理起来就非常麻烦了。

另外,跟Namespace的情况类似,Cgroups对资源的限制能力也有很多不完善的地方,被提及最多的自然是/proc文件系统的问题。

众所周知,Linux下的/proc目录存储的是记录当前内核运行状态的一系列特殊文件,用户可以通过访问这些文件,查看系统以及当前正在运行的进程的信息,比如CPU使用情况、内存占用率等,这些文件也是top指令查看系统信息的主要数据来源。

但是,你如果在容器里执行top指令,就会发现,它显示的信息居然是宿主机的CPU和内存数据,而不是当前容器的数据。

造成这个问题的原因就是,/proc文件系统并不知道用户通过Cgroups给这个容器做了什么样的资源限制,即:/proc文件系统不了解Cgroups限制的存在。

在生产环境中,这个问题必须进行修正,否则应用程序在容器里读取到的CPU核数、可用内存等信息都是宿主机上的数据,这会给应用的运行带来非常大的困惑和风险。这也是在企业中,容器化应用碰到的一个常见问题,也是容器相较于虚拟机另一个不尽如人意的地方。

思考题

  1. 你是否知道如何修复容器中的top指令以及/proc文件系统中的信息呢?(提示:lxcfs)

  2. 在从虚拟机向容器环境迁移应用的过程中,你还遇到哪些容器与虚拟机的不一致问题?

感谢你的收听,欢迎给我留言一起讨论,也欢迎分享给更多的朋友一起阅读。

07-白话容器基础(三):深入理解容器镜像

你好,我是张磊。我在今天这篇文章的最后,放置了一张Kubernetes的技能图谱,希望对你有帮助。

在前两次的分享中,我讲解了Linux容器最基础的两种技术:Namespace和Cgroups。希望此时,你已经彻底理解了“容器的本质是一种特殊的进程”这个最重要的概念。

而正如我前面所说的,Namespace的作用是“隔离”,它让应用进程只能看到该Namespace内的“世界”;而Cgroups的作用是“限制”,它给这个“世界”围上了一圈看不见的墙。这么一折腾,进程就真的被“装”在了一个与世隔绝的房间里,而这些房间就是PaaS项目赖以生存的应用“沙盒”。

可是,还有一个问题不知道你有没有仔细思考过:这个房间四周虽然有了墙,但是如果容器进程低头一看地面,又是怎样一副景象呢?

换句话说,容器里的进程看到的文件系统又是什么样子的呢?

可能你立刻就能想到,这一定是一个关于Mount Namespace的问题:容器里的应用进程,理应看到一份完全独立的文件系统。这样,它就可以在自己的容器目录(比如/tmp)下进行操作,而完全不会受宿主机以及其他容器的影响。

那么,真实情况是这样吗?

“左耳朵耗子”叔在多年前写的一篇关于Docker基础知识的博客里,曾经介绍过一段小程序。这段小程序的作用是,在创建子进程时开启指定的Namespace。

下面,我们不妨使用它来验证一下刚刚提到的问题。

#define _GNU_SOURCE
#include <sys/mount.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <sched.h>
#include <signal.h>
#include <unistd.h>
#define STACK_SIZE (1024 * 1024)
static char container_stack[STACK_SIZE];
char* const container_args[] = {
  "/bin/bash",
  NULL
};

int container_main(void* arg)
{  
  printf("Container - inside the container!\n");
  execv(container_args[0], container_args);
  printf("Something's wrong!\n");
  return 1;
}

int main()
{
  printf("Parent - start a container!\n");
  int container_pid = clone(container_main, container_stack+STACK_SIZE, CLONE_NEWNS | SIGCHLD , NULL);
  waitpid(container_pid, NULL, 0);
  printf("Parent - container stopped!\n");
  return 0;
}

这段代码的功能非常简单:在main函数里,我们通过clone()系统调用创建了一个新的子进程container_main,并且声明要为它启用Mount Namespace(即:CLONE_NEWNS标志)。

而这个子进程执行的,是一个“/bin/bash”程序,也就是一个shell。所以这个shell就运行在了Mount Namespace的隔离环境中。

我们来一起编译一下这个程序:

$ gcc -o ns ns.c
$ ./ns
Parent - start a container!
Container - inside the container!

这样,我们就进入了这个“容器”当中。可是,如果在“容器”里执行一下ls指令的话,我们就会发现一个有趣的现象: /tmp目录下的内容跟宿主机的内容是一样的。

$ ls /tmp
# 你会看到好多宿主机的文件

也就是说:

即使开启了Mount Namespace,容器进程看到的文件系统也跟宿主机完全一样。

这是怎么回事呢?

仔细思考一下,你会发现这其实并不难理解:Mount Namespace修改的,是容器进程对文件系统“挂载点”的认知。但是,这也就意味着,只有在“挂载”这个操作发生之后,进程的视图才会被改变。而在此之前,新创建的容器会直接继承宿主机的各个挂载点。

这时,你可能已经想到了一个解决办法:创建新进程时,除了声明要启用Mount Namespace之外,我们还可以告诉容器进程,有哪些目录需要重新挂载,就比如这个/tmp目录。于是,我们在容器进程执行前可以添加一步重新挂载 /tmp目录的操作:

int container_main(void* arg)
{
  printf("Container - inside the container!\n");
  // 如果你的机器的根目录的挂载类型是shared,那必须先重新挂载根目录
  // mount("", "/", NULL, MS_PRIVATE, "");
  mount("none", "/tmp", "tmpfs", 0, "");
  execv(container_args[0], container_args);
  printf("Something's wrong!\n");
  return 1;
}

可以看到,在修改后的代码里,我在容器进程启动之前,加上了一句mount(“none”, “/tmp”, “tmpfs”, 0, “”)语句。就这样,我告诉了容器以tmpfs(内存盘)格式,重新挂载了/tmp目录。

这段修改后的代码,编译执行后的结果又如何呢?我们可以试验一下:

$ gcc -o ns ns.c
$ ./ns
Parent - start a container!
Container - inside the container!
$ ls /tmp

可以看到,这次/tmp变成了一个空目录,这意味着重新挂载生效了。我们可以用mount -l检查一下:

$ mount -l | grep tmpfs
none on /tmp type tmpfs (rw,relatime)

可以看到,容器里的/tmp目录是以tmpfs方式单独挂载的。

更重要的是,因为我们创建的新进程启用了Mount Namespace,所以这次重新挂载的操作,只在容器进程的Mount Namespace中有效。如果在宿主机上用mount -l来检查一下这个挂载,你会发现它是不存在的:

# 在宿主机上
$ mount -l | grep tmpfs

这就是Mount Namespace跟其他Namespace的使用略有不同的地方:它对容器进程视图的改变,一定是伴随着挂载操作(mount)才能生效。

可是,作为一个普通用户,我们希望的是一个更友好的情况:每当创建一个新容器时,我希望容器进程看到的文件系统就是一个独立的隔离环境,而不是继承自宿主机的文件系统。怎么才能做到这一点呢?

不难想到,我们可以在容器进程启动之前重新挂载它的整个根目录“/”。而由于Mount Namespace的存在,这个挂载对宿主机不可见,所以容器进程就可以在里面随便折腾了。

在Linux操作系统里,有一个名为chroot的命令可以帮助你在shell中方便地完成这个工作。顾名思义,它的作用就是帮你“change root file system”,即改变进程的根目录到你指定的位置。它的用法也非常简单。

假设,我们现在有一个$HOME/test目录,想要把它作为一个/bin/bash进程的根目录。

首先,创建一个test目录和几个lib文件夹:

$ mkdir -p $HOME/test
$ mkdir -p $HOME/test/{bin,lib64,lib}
$ cd $T

然后,把bash命令拷贝到test目录对应的bin路径下:

$ cp -v /bin/{bash,ls} $HOME/test/bin

接下来,把bash命令需要的所有so文件,也拷贝到test目录对应的lib路径下。找到so文件可以用ldd 命令:

$ T=$HOME/test
$ list="$(ldd /bin/ls | egrep -o '/lib.*\.[0-9]')"
$ for i in $list; do cp -v "$i" "${T}${i}"; done

最后,执行chroot命令,告诉操作系统,我们将使用$HOME/test目录作为/bin/bash进程的根目录:

$ chroot $HOME/test /bin/bash

这时,你如果执行"ls /",就会看到,它返回的都是$HOME/test目录下面的内容,而不是宿主机的内容。

更重要的是,对于被chroot的进程来说,它并不会感受到自己的根目录已经被“修改”成$HOME/test了。

这种视图被修改的原理,是不是跟我之前介绍的Linux Namespace很类似呢?

没错!

实际上,Mount Namespace正是基于对chroot的不断改良才被发明出来的,它也是Linux操作系统里的第一个Namespace。

当然,为了能够让容器的这个根目录看起来更“真实”,我们一般会在这个容器的根目录下挂载一个完整操作系统的文件系统,比如Ubuntu16.04的ISO。这样,在容器启动之后,我们在容器里通过执行"ls /"查看根目录下的内容,就是Ubuntu 16.04的所有目录和文件。

而这个挂载在容器根目录上、用来为容器进程提供隔离后执行环境的文件系统,就是所谓的“容器镜像”。它还有一个更为专业的名字,叫作:rootfs(根文件系统)。

所以,一个最常见的rootfs,或者说容器镜像,会包括如下所示的一些目录和文件,比如/bin,/etc,/proc等等:

$ ls /
bin dev etc home lib lib64 mnt opt proc root run sbin sys tmp usr var

而你进入容器之后执行的/bin/bash,就是/bin目录下的可执行文件,与宿主机的/bin/bash完全不同。

现在,你应该可以理解,对Docker项目来说,它最核心的原理实际上就是为待创建的用户进程:

  1. 启用Linux Namespace配置;

  2. 设置指定的Cgroups参数;

  3. 切换进程的根目录(Change Root)。

这样,一个完整的容器就诞生了。不过,Docker项目在最后一步的切换上会优先使用pivot_root系统调用,如果系统不支持,才会使用chroot。这两个系统调用虽然功能类似,但是也有细微的区别,这一部分小知识就交给你课后去探索了。

另外,需要明确的是,rootfs只是一个操作系统所包含的文件、配置和目录,并不包括操作系统内核。在Linux操作系统中,这两部分是分开存放的,操作系统只有在开机启动时才会加载指定版本的内核镜像。

所以说,rootfs只包括了操作系统的“躯壳”,并没有包括操作系统的“灵魂”。

那么,对于容器来说,这个操作系统的“灵魂”又在哪里呢?

实际上,同一台机器上的所有容器,都共享宿主机操作系统的内核。

这就意味着,如果你的应用程序需要配置内核参数、加载额外的内核模块,以及跟内核进行直接的交互,你就需要注意了:这些操作和依赖的对象,都是宿主机操作系统的内核,它对于该机器上的所有容器来说是一个“全局变量”,牵一发而动全身。

这也是容器相比于虚拟机的主要缺陷之一:毕竟后者不仅有模拟出来的硬件机器充当沙盒,而且每个沙盒里还运行着一个完整的Guest OS给应用随便折腾。

不过,正是由于rootfs的存在,容器才有了一个被反复宣传至今的重要特性:一致性。

什么是容器的“一致性”呢?

我在专栏的第一篇文章《小鲸鱼大事记(一):初出茅庐》中曾经提到过:由于云端与本地服务器环境不同,应用的打包过程,一直是使用PaaS时最“痛苦”的一个步骤。

但有了容器之后,更准确地说,有了容器镜像(即rootfs)之后,这个问题被非常优雅地解决了。

由于rootfs里打包的不只是应用,而是整个操作系统的文件和目录,也就意味着,应用以及它运行所需要的所有依赖,都被封装在了一起。

事实上,对于大多数开发者而言,他们对应用依赖的理解,一直局限在编程语言层面。比如Golang的Godeps.json。但实际上,一个一直以来很容易被忽视的事实是,对一个应用来说,操作系统本身才是它运行所需要的最完整的“依赖库”。

有了容器镜像“打包操作系统”的能力,这个最基础的依赖环境也终于变成了应用沙盒的一部分。这就赋予了容器所谓的一致性:无论在本地、云端,还是在一台任何地方的机器上,用户只需要解压打包好的容器镜像,那么这个应用运行所需要的完整的执行环境就被重现出来了。

这种深入到操作系统级别的运行环境一致性,打通了应用在本地开发和远端执行环境之间难以逾越的鸿沟。

不过,这时你可能已经发现了另一个非常棘手的问题:难道我每开发一个应用,或者升级一下现有的应用,都要重复制作一次rootfs吗?

比如,我现在用Ubuntu操作系统的ISO做了一个rootfs,然后又在里面安装了Java环境,用来部署我的Java应用。那么,我的另一个同事在发布他的Java应用时,显然希望能够直接使用我安装过Java环境的rootfs,而不是重复这个流程。

一种比较直观的解决办法是,我在制作rootfs的时候,每做一步“有意义”的操作,就保存一个rootfs出来,这样其他同事就可以按需求去用他需要的rootfs了。

但是,这个解决办法并不具备推广性。原因在于,一旦你的同事们修改了这个rootfs,新旧两个rootfs之间就没有任何关系了。这样做的结果就是极度的碎片化。

那么,既然这些修改都基于一个旧的rootfs,我们能不能以增量的方式去做这些修改呢?这样做的好处是,所有人都只需要维护相对于base rootfs修改的增量内容,而不是每次修改都制造一个“fork”。

答案当然是肯定的。

这也正是为何,Docker公司在实现Docker镜像时并没有沿用以前制作rootfs的标准流程,而是做了一个小小的创新:

Docker在镜像的设计中,引入了层(layer)的概念。也就是说,用户制作镜像的每一步操作,都会生成一个层,也就是一个增量rootfs。

当然,这个想法不是凭空臆造出来的,而是用到了一种叫作联合文件系统(Union File System)的能力。

Union File System也叫UnionFS,最主要的功能是将多个不同位置的目录联合挂载(union mount)到同一个目录下。比如,我现在有两个目录A和B,它们分别有两个文件:

$ tree
.
├── A
│  ├── a
│  └── x
└── B
  ├── b
  └── x

然后,我使用联合挂载的方式,将这两个目录挂载到一个公共的目录C上:

$ mkdir C
$ mount -t aufs -o dirs=./A:./B none ./C

这时,我再查看目录C的内容,就能看到目录A和B下的文件被合并到了一起:

$ tree ./C
./C
├── a
├── b
└── x

可以看到,在这个合并后的目录C里,有a、b、x三个文件,并且x文件只有一份。这,就是“合并”的含义。此外,如果你在目录C里对a、b、x文件做修改,这些修改也会在对应的目录A、B中生效。

那么,在Docker项目中,又是如何使用这种Union File System的呢?

我的环境是Ubuntu 16.04和Docker CE 18.05,这对组合默认使用的是AuFS这个联合文件系统的实现。你可以通过docker info命令,查看到这个信息。

AuFS的全称是Another UnionFS,后改名为Alternative UnionFS,再后来干脆改名叫作Advance UnionFS,从这些名字中你应该能看出这样两个事实:

  1. 它是对Linux原生UnionFS的重写和改进;

  2. 它的作者怨气好像很大。我猜是Linus Torvalds(Linux之父)一直不让AuFS进入Linux内核主干的缘故,所以我们只能在Ubuntu和Debian这些发行版上使用它。

对于AuFS来说,它最关键的目录结构在/var/lib/docker路径下的diff目录:

/var/lib/docker/aufs/diff/<layer_id>

而这个目录的作用,我们不妨通过一个具体例子来看一下。

现在,我们启动一个容器,比如:

$ docker run -d ubuntu:latest sleep 3600

这时候,Docker就会从Docker Hub上拉取一个Ubuntu镜像到本地。

这个所谓的“镜像”,实际上就是一个Ubuntu操作系统的rootfs,它的内容是Ubuntu操作系统的所有文件和目录。不过,与之前我们讲述的rootfs稍微不同的是,Docker镜像使用的rootfs,往往由多个“层”组成:

$ docker image inspect ubuntu:latest
...
     "RootFS": {
      "Type": "layers",
      "Layers": [
        "sha256:f49017d4d5ce9c0f544c...",
        "sha256:8f2b771487e9d6354080...",
        "sha256:ccd4d61916aaa2159429...",
        "sha256:c01d74f99de40e097c73...",
        "sha256:268a067217b5fe78e000..."
      ]
    }

可以看到,这个Ubuntu镜像,实际上由五个层组成。这五个层就是五个增量rootfs,每一层都是Ubuntu操作系统文件与目录的一部分;而在使用镜像时,Docker会把这些增量联合挂载在一个统一的挂载点上(等价于前面例子里的“/C”目录)。

这个挂载点就是/var/lib/docker/aufs/mnt/,比如:

/var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fcfa2a2f5c89dc21ee30e166be823ceaeba15dce645b3e

不出意外的,这个目录里面正是一个完整的Ubuntu操作系统:

$ ls /var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fcfa2a2f5c89dc21ee30e166be823ceaeba15dce645b3e
bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var

那么,前面提到的五个镜像层,又是如何被联合挂载成这样一个完整的Ubuntu文件系统的呢?

这个信息记录在AuFS的系统目录/sys/fs/aufs下面。

首先,通过查看AuFS的挂载信息,我们可以找到这个目录对应的AuFS的内部ID(也叫:si):

$ cat /proc/mounts| grep aufs
none /var/lib/docker/aufs/mnt/6e3be5d2ecccae7cc0fc... aufs rw,relatime,si=972c6d361e6b32ba,dio,dirperm1 0 0

即,si=972c6d361e6b32ba。

然后使用这个ID,你就可以在/sys/fs/aufs下查看被联合挂载在一起的各个层的信息:

$ cat /sys/fs/aufs/si_972c6d361e6b32ba/br[0-9]*
/var/lib/docker/aufs/diff/6e3be5d2ecccae7cc...=rw
/var/lib/docker/aufs/diff/6e3be5d2ecccae7cc...-init=ro+wh
/var/lib/docker/aufs/diff/32e8e20064858c0f2...=ro+wh
/var/lib/docker/aufs/diff/2b8858809bce62e62...=ro+wh
/var/lib/docker/aufs/diff/20707dce8efc0d267...=ro+wh
/var/lib/docker/aufs/diff/72b0744e06247c7d0...=ro+wh
/var/lib/docker/aufs/diff/a524a729adadedb90...=ro+wh

从这些信息里,我们可以看到,镜像的层都放置在/var/lib/docker/aufs/diff目录下,然后被联合挂载在/var/lib/docker/aufs/mnt里面。

而且,从这个结构可以看出来,这个容器的rootfs由如下图所示的三部分组成:

第一部分,只读层。

它是这个容器的rootfs最下面的五层,对应的正是ubuntu:latest镜像的五层。可以看到,它们的挂载方式都是只读的(ro+wh,即readonly+whiteout,至于什么是whiteout,我下面马上会讲到)。

这时,我们可以分别查看一下这些层的内容:

$ ls /var/lib/docker/aufs/diff/72b0744e06247c7d0...
etc sbin usr var
$ ls /var/lib/docker/aufs/diff/32e8e20064858c0f2...
run
$ ls /var/lib/docker/aufs/diff/a524a729adadedb900...
bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var

可以看到,这些层,都以增量的方式分别包含了Ubuntu操作系统的一部分。

第二部分,可读写层。

它是这个容器的rootfs最上面的一层(6e3be5d2ecccae7cc),它的挂载方式为:rw,即read write。在没有写入文件之前,这个目录是空的。而一旦在容器里做了写操作,你修改产生的内容就会以增量的方式出现在这个层中。

可是,你有没有想到这样一个问题:如果我现在要做的,是删除只读层里的一个文件呢?

为了实现这样的删除操作,AuFS会在可读写层创建一个whiteout文件,把只读层里的文件“遮挡”起来。

比如,你要删除只读层里一个名叫foo的文件,那么这个删除操作实际上是在可读写层创建了一个名叫.wh.foo的文件。这样,当这两个层被联合挂载之后,foo文件就会被.wh.foo文件“遮挡”起来,“消失”了。这个功能,就是“ro+wh”的挂载方式,即只读+whiteout的含义。我喜欢把whiteout形象地翻译为:“白障”。

所以,最上面这个可读写层的作用,就是专门用来存放你修改rootfs后产生的增量,无论是增、删、改,都发生在这里。而当我们使用完了这个被修改过的容器之后,还可以使用docker commit和push指令,保存这个被修改过的可读写层,并上传到Docker Hub上,供其他人使用;而与此同时,原先的只读层里的内容则不会有任何变化。这,就是增量rootfs的好处。

第三部分,Init层。

它是一个以“-init”结尾的层,夹在只读层和读写层之间。Init层是Docker项目单独生成的一个内部层,专门用来存放/etc/hosts、/etc/resolv.conf等信息。

需要这样一层的原因是,这些文件本来属于只读的Ubuntu镜像的一部分,但是用户往往需要在启动容器时写入一些指定的值比如hostname,所以就需要在可读写层对它们进行修改。

可是,这些修改往往只对当前的容器有效,我们并不希望执行docker commit时,把这些信息连同可读写层一起提交掉。

所以,Docker做法是,在修改了这些文件之后,以一个单独的层挂载了出来。而用户执行docker commit只会提交可读写层,所以是不包含这些内容的。

最终,这7个层都被联合挂载到/var/lib/docker/aufs/mnt目录下,表现为一个完整的Ubuntu操作系统供容器使用。

总结

在今天的分享中,我着重介绍了Linux容器文件系统的实现方式。而这种机制,正是我们经常提到的容器镜像,也叫作:rootfs。它只是一个操作系统的所有文件和目录,并不包含内核,最多也就几百兆。而相比之下,传统虚拟机的镜像大多是一个磁盘的“快照”,磁盘有多大,镜像就至少有多大。

通过结合使用Mount Namespace和rootfs,容器就能够为进程构建出一个完善的文件系统隔离环境。当然,这个功能的实现还必须感谢chroot和pivot_root这两个系统调用切换进程根目录的能力。

而在rootfs的基础上,Docker公司创新性地提出了使用多个增量rootfs联合挂载一个完整rootfs的方案,这就是容器镜像中“层”的概念。

通过“分层镜像”的设计,以Docker镜像为核心,来自不同公司、不同团队的技术人员被紧密地联系在了一起。而且,由于容器镜像的操作是增量式的,这样每次镜像拉取、推送的内容,比原本多个完整的操作系统的大小要小得多;而共享层的存在,可以使得所有这些容器镜像需要的总空间,也比每个镜像的总和要小。这样就使得基于容器镜像的团队协作,要比基于动则几个GB的虚拟机磁盘镜像的协作要敏捷得多。

更重要的是,一旦这个镜像被发布,那么你在全世界的任何一个地方下载这个镜像,得到的内容都完全一致,可以完全复现这个镜像制作者当初的完整环境。这,就是容器技术“强一致性”的重要体现。

而这种价值正是支撑Docker公司在2014~2016年间迅猛发展的核心动力。容器镜像的发明,不仅打通了“开发-测试-部署”流程的每一个环节,更重要的是:

容器镜像将会成为未来软件的主流发布方式。

思考题

  1. 既然容器的rootfs(比如,Ubuntu镜像),是以只读方式挂载的,那么又如何在容器里修改Ubuntu镜像的内容呢?(提示:Copy-on-Write)

  2. 除了AuFS,你知道Docker项目还支持哪些UnionFS实现吗?你能说出不同宿主机环境下推荐使用哪种实现吗?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

点击这里查看大图。

08-白话容器基础(四):重新认识Docker容器

你好,我是张磊。今天我和你分享的主题是:白话容器基础之重新认识Docker容器。

在前面的三次分享中,我分别从Linux Namespace的隔离能力、Linux Cgroups的限制能力,以及基于rootfs的文件系统三个角度,为你剖析了一个Linux容器的核心实现原理。

备注:之所以要强调Linux容器,是因为比如Docker on Mac,以及Windows Docker(Hyper-V实现),实际上是基于虚拟化技术实现的,跟我们这个专栏着重介绍的Linux容器完全不同。

而在今天的分享中,我会通过一个实际案例,对“白话容器基础”系列的所有内容做一次深入的总结和扩展。希望通过这次的讲解,能够让你更透彻地理解Docker容器的本质。

在开始实践之前,你需要准备一台Linux机器,并安装Docker。这个流程我就不再赘述了。

这一次,我要用Docker部署一个用Python编写的Web应用。这个应用的代码部分(app.py)非常简单:

from flask import Flask
import socket
import os

app = Flask(__name__)

@app.route('/')
def hello():
    html = "<h3>Hello {name}!</h3>" \
           "<b>Hostname:</b> {hostname}<br/>"          
    return html.format(name=os.getenv("NAME", "world"), hostname=socket.gethostname())
    
if __name__ == "__main__":
    app.run(host='0.0.0.0', port=80)

在这段代码中,我使用Flask框架启动了一个Web服务器,而它唯一的功能是:如果当前环境中有“NAME”这个环境变量,就把它打印在“Hello”之后,否则就打印“Hello world”,最后再打印出当前环境的hostname。

这个应用的依赖,则被定义在了同目录下的requirements.txt文件里,内容如下所示:

$ cat requirements.txt
Flask

而将这样一个应用容器化的第一步,是制作容器镜像。

不过,相较于我之前介绍的制作rootfs的过程,Docker为你提供了一种更便捷的方式,叫作Dockerfile,如下所示。

# 使用官方提供的Python开发镜像作为基础镜像
FROM python:2.7-slim

# 将工作目录切换为/app
WORKDIR /app

# 将当前目录下的所有内容复制到/app下
ADD . /app

# 使用pip命令安装这个应用所需要的依赖
RUN pip install --trusted-host pypi.python.org -r requirements.txt

# 允许外界访问容器的80端口
EXPOSE 80

# 设置环境变量
ENV NAME World

# 设置容器进程为:python app.py,即:这个Python应用的启动命令
CMD ["python", "app.py"]

通过这个文件的内容,你可以看到Dockerfile的设计思想,是使用一些标准的原语(即大写高亮的词语),描述我们所要构建的Docker镜像。并且这些原语,都是按顺序处理的。

比如FROM原语,指定了“python:2.7-slim”这个官方维护的基础镜像,从而免去了安装Python等语言环境的操作。否则,这一段我们就得这么写了:

FROM ubuntu:latest
RUN apt-get update -yRUN apt-get install -y python-pip python-dev build-essential
...

其中,RUN原语就是在容器里执行shell命令的意思。

而WORKDIR,意思是在这一句之后,Dockerfile后面的操作都以这一句指定的/app目录作为当前目录。

所以,到了最后的CMD,意思是Dockerfile指定python app.py为这个容器的进程。这里,app.py的实际路径是/app/app.py。所以,CMD ["python", "app.py"]等价于"docker run <image> python app.py"。

另外,在使用Dockerfile时,你可能还会看到一个叫作ENTRYPOINT的原语。实际上,它和CMD都是Docker容器进程启动所必需的参数,完整执行格式是:“ENTRYPOINT CMD”。

但是,默认情况下,Docker会为你提供一个隐含的ENTRYPOINT,即:/bin/sh -c。所以,在不指定ENTRYPOINT时,比如在我们这个例子里,实际上运行在容器里的完整进程是:/bin/sh -c "python app.py",即CMD的内容就是ENTRYPOINT的参数。

备注:基于以上原因,我们后面会统一称Docker容器的启动进程为ENTRYPOINT,而不是CMD。

需要注意的是,Dockerfile里的原语并不都是指对容器内部的操作。就比如ADD,它指的是把当前目录(即Dockerfile所在的目录)里的文件,复制到指定容器内的目录当中。

读懂这个Dockerfile之后,我再把上述内容,保存到当前目录里一个名叫“Dockerfile”的文件中:

$ ls
Dockerfile  app.py   requirements.txt

接下来,我就可以让Docker制作这个镜像了,在当前目录执行:

$ docker build -t helloworld .

其中,-t的作用是给这个镜像加一个Tag,即:起一个好听的名字。docker build会自动加载当前目录下的Dockerfile文件,然后按照顺序,执行文件中的原语。而这个过程,实际上可以等同于Docker使用基础镜像启动了一个容器,然后在容器中依次执行Dockerfile中的原语。

需要注意的是,Dockerfile中的每个原语执行后,都会生成一个对应的镜像层。即使原语本身并没有明显地修改文件的操作(比如,ENV原语),它对应的层也会存在。只不过在外界看来,这个层是空的。

docker build操作完成后,我可以通过docker images命令查看结果:

$ docker image ls

REPOSITORY            TAG                 IMAGE ID
helloworld         latest              653287cdf998

通过这个镜像ID,你就可以使用在《白话容器基础(三):深入理解容器镜像》中讲过的方法,查看这些新增的层在AuFS路径下对应的文件和目录了。

接下来,我使用这个镜像,通过docker run命令启动容器:

$ docker run -p 4000:80 helloworld

在这一句命令中,镜像名helloworld后面,我什么都不用写,因为在Dockerfile中已经指定了CMD。否则,我就得把进程的启动命令加在后面:

$ docker run -p 4000:80 helloworld python app.py

容器启动之后,我可以使用docker ps命令看到:

$ docker ps
CONTAINER ID        IMAGE               COMMAND             CREATED
4ddf4638572d        helloworld       "python app.py"     10 seconds ago

同时,我已经通过-p 4000:80告诉了Docker,请把容器内的80端口映射在宿主机的4000端口上。

这样做的目的是,只要访问宿主机的4000端口,我就可以看到容器里应用返回的结果:

$ curl http://localhost:4000
<h3>Hello World!</h3><b>Hostname:</b> 4ddf4638572d<br/>

否则,我就得先用docker inspect命令查看容器的IP地址,然后访问“http://<容器IP地址>:80”才可以看到容器内应用的返回。

至此,我已经使用容器完成了一个应用的开发与测试,如果现在想要把这个容器的镜像上传到DockerHub上分享给更多的人,我要怎么做呢?

为了能够上传镜像,我首先需要注册一个Docker Hub账号,然后使用docker login命令登录

接下来,我要用docker tag命令给容器镜像起一个完整的名字

$ docker tag helloworld geektime/helloworld:v1

注意:你自己做实验时,请将"geektime"替换成你自己的Docker Hub账户名称,比如zhangsan/helloworld:v1

其中,geektime是我在Docker Hub上的用户名,它的“学名”叫镜像仓库(Repository);“/”后面的helloworld是这个镜像的名字,而“v1”则是我给这个镜像分配的版本号。

然后,我执行docker push:

$ docker push geektime/helloworld:v1

这样,我就可以把这个镜像上传到Docker Hub上了。

此外,我还可以使用docker commit指令,把一个正在运行的容器,直接提交为一个镜像。一般来说,需要这么操作原因是:这个容器运行起来后,我又在里面做了一些操作,并且要把操作结果保存到镜像里,比如:

$ docker exec -it 4ddf4638572d /bin/sh
# 在容器内部新建了一个文件
root@4ddf4638572d:/app# touch test.txt
root@4ddf4638572d:/app# exit

#将这个新建的文件提交到镜像中保存
$ docker commit 4ddf4638572d geektime/helloworld:v2

这里,我使用了docker exec命令进入到了容器当中。在了解了Linux Namespace的隔离机制后,你应该会很自然地想到一个问题:docker exec是怎么做到进入容器里的呢?

实际上,Linux Namespace创建的隔离空间虽然看不见摸不着,但一个进程的Namespace信息在宿主机上是确确实实存在的,并且是以一个文件的方式存在。

比如,通过如下指令,你可以看到当前正在运行的Docker容器的进程号(PID)是25686:

$ docker inspect --format '{{ .State.Pid }}'  4ddf4638572d
25686

这时,你可以通过查看宿主机的proc文件,看到这个25686进程的所有Namespace对应的文件:

$ ls -l  /proc/25686/ns
total 0
lrwxrwxrwx 1 root root 0 Aug 13 14:05 cgroup -> cgroup:[4026531835]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 ipc -> ipc:[4026532278]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 mnt -> mnt:[4026532276]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 net -> net:[4026532281]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 pid -> pid:[4026532279]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 pid_for_children -> pid:[4026532279]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 user -> user:[4026531837]
lrwxrwxrwx 1 root root 0 Aug 13 14:05 uts -> uts:[4026532277]

可以看到,一个进程的每种Linux Namespace,都在它对应的/proc/[进程号]/ns下有一个对应的虚拟文件,并且链接到一个真实的Namespace文件上。

有了这样一个可以“hold住”所有Linux Namespace的文件,我们就可以对Namespace做一些很有意义事情了,比如:加入到一个已经存在的Namespace当中。

这也就意味着:一个进程,可以选择加入到某个进程已有的Namespace当中,从而达到“进入”这个进程所在容器的目的,这正是docker exec的实现原理。

而这个操作所依赖的,乃是一个名叫setns()的Linux系统调用。它的调用方法,我可以用如下一段小程序为你说明:

#define _GNU_SOURCE
#include <fcntl.h>
#include <sched.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>

#define errExit(msg) do { perror(msg); exit(EXIT_FAILURE);} while (0)

int main(int argc, char *argv[]) {
    int fd;
    
    fd = open(argv[1], O_RDONLY);
    if (setns(fd, 0) == -1) {
        errExit("setns");
    }
    execvp(argv[2], &argv[2]);
    errExit("execvp");
}

这段代码功能非常简单:它一共接收两个参数,第一个参数是argv[1],即当前进程要加入的Namespace文件的路径,比如/proc/25686/ns/net;而第二个参数,则是你要在这个Namespace里运行的进程,比如/bin/bash。

这段代码的核心操作,则是通过open()系统调用打开了指定的Namespace文件,并把这个文件的描述符fd交给setns()使用。在setns()执行后,当前进程就加入了这个文件对应的Linux Namespace当中了。

现在,你可以编译执行一下这个程序,加入到容器进程(PID=25686)的Network Namespace中:

$ gcc -o set_ns set_ns.c
$ ./set_ns /proc/25686/ns/net /bin/bash
$ ifconfig
eth0      Link encap:Ethernet  HWaddr 02:42:ac:11:00:02  
          inet addr:172.17.0.2  Bcast:0.0.0.0  Mask:255.255.0.0
          inet6 addr: fe80::42:acff:fe11:2/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:12 errors:0 dropped:0 overruns:0 frame:0
          TX packets:10 errors:0 dropped:0 overruns:0 carrier:0
      collisions:0 txqueuelen:0
          RX bytes:976 (976.0 B)  TX bytes:796 (796.0 B)

lo        Link encap:Local Loopback  
          inet addr:127.0.0.1  Mask:255.0.0.0
          inet6 addr: ::1/128 Scope:Host
          UP LOOPBACK RUNNING  MTU:65536  Metric:1
          RX packets:0 errors:0 dropped:0 overruns:0 frame:0
          TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
     collisions:0 txqueuelen:1000
          RX bytes:0 (0.0 B)  TX bytes:0 (0.0 B)

正如上所示,当我们执行ifconfig命令查看网络设备时,我会发现能看到的网卡“变少”了:只有两个。而我的宿主机则至少有四个网卡。这是怎么回事呢?

实际上,在setns()之后我看到的这两个网卡,正是我在前面启动的Docker容器里的网卡。也就是说,我新创建的这个/bin/bash进程,由于加入了该容器进程(PID=25686)的Network Namepace,它看到的网络设备与这个容器里是一样的,即:/bin/bash进程的网络设备视图,也被修改了。

而一旦一个进程加入到了另一个Namespace当中,在宿主机的Namespace文件上,也会有所体现。

在宿主机上,你可以用ps指令找到这个set_ns程序执行的/bin/bash进程,其真实的PID是28499:

# 在宿主机上
ps aux | grep /bin/bash
root     28499  0.0  0.0 19944  3612 pts/0    S    14:15   0:00 /bin/bash

这时,如果按照前面介绍过的方法,查看一下这个PID=28499的进程的Namespace,你就会发现这样一个事实:

$ ls -l /proc/28499/ns/net
lrwxrwxrwx 1 root root 0 Aug 13 14:18 /proc/28499/ns/net -> net:[4026532281]

$ ls -l  /proc/25686/ns/net
lrwxrwxrwx 1 root root 0 Aug 13 14:05 /proc/25686/ns/net -> net:[4026532281]

在/proc/[PID]/ns/net目录下,这个PID=28499进程,与我们前面的Docker容器进程(PID=25686)指向的Network Namespace文件完全一样。这说明这两个进程,共享了这个名叫net:[4026532281]的Network Namespace。

此外,Docker还专门提供了一个参数,可以让你启动一个容器并“加入”到另一个容器的Network Namespace里,这个参数就是-net,比如:

$ docker run -it --net container:4ddf4638572d busybox ifconfig

这样,我们新启动的这个容器,就会直接加入到ID=4ddf4638572d的容器,也就是我们前面的创建的Python应用容器(PID=25686)的Network Namespace中。所以,这里ifconfig返回的网卡信息,跟我前面那个小程序返回的结果一模一样,你也可以尝试一下。

而如果我指定–net=host,就意味着这个容器不会为进程启用Network Namespace。这就意味着,这个容器拆除了Network Namespace的“隔离墙”,所以,它会和宿主机上的其他普通进程一样,直接共享宿主机的网络栈。这就为容器直接操作和使用宿主机网络提供了一个渠道。

转了一个大圈子,我其实是为你详细解读了docker exec这个操作背后,Linux Namespace更具体的工作原理。

这种通过操作系统进程相关的知识,逐步剖析Docker容器的方法,是理解容器的一个关键思路,希望你一定要掌握。

现在,我们再一起回到前面提交镜像的操作docker commit上来吧。

docker commit,实际上就是在容器运行起来后,把最上层的“可读写层”,加上原先容器镜像的只读层,打包组成了一个新的镜像。当然,下面这些只读层在宿主机上是共享的,不会占用额外的空间。

而由于使用了联合文件系统,你在容器里对镜像rootfs所做的任何修改,都会被操作系统先复制到这个可读写层,然后再修改。这就是所谓的:Copy-on-Write。

而正如前所说,Init层的存在,就是为了避免你执行docker commit时,把Docker自己对/etc/hosts等文件做的修改,也一起提交掉。

有了新的镜像,我们就可以把它推送到Docker Hub上了:

$ docker push geektime/helloworld:v2

你可能还会有这样的问题:我在企业内部,能不能也搭建一个跟Docker Hub类似的镜像上传系统呢?

当然可以,这个统一存放镜像的系统,就叫作Docker Registry。感兴趣的话,你可以查看Docker的官方文档,以及VMware的Harbor项目

最后,我再来讲解一下Docker项目另一个重要的内容:Volume(数据卷)。

前面我已经介绍过,容器技术使用了rootfs机制和Mount Namespace,构建出了一个同宿主机完全隔离开的文件系统环境。这时候,我们就需要考虑这样两个问题:

  1. 容器里进程新建的文件,怎么才能让宿主机获取到?

  2. 宿主机上的文件和目录,怎么才能让容器里的进程访问到?

这正是Docker Volume要解决的问题:Volume机制,允许你将宿主机上指定的目录或者文件,挂载到容器里面进行读取和修改操作。

在Docker项目里,它支持两种Volume声明方式,可以把宿主机目录挂载进容器的/test目录当中:

$ docker run -v /test ...
$ docker run -v /home:/test ...

而这两种声明方式的本质,实际上是相同的:都是把一个宿主机的目录挂载进了容器的/test目录。

只不过,在第一种情况下,由于你并没有显示声明宿主机目录,那么Docker就会默认在宿主机上创建一个临时目录 /var/lib/docker/volumes/[VOLUME_ID]/_data,然后把它挂载到容器的/test目录上。而在第二种情况下,Docker就直接把宿主机的/home目录挂载到容器的/test目录上。

那么,Docker又是如何做到把一个宿主机上的目录或者文件,挂载到容器里面去呢?难道又是Mount Namespace的黑科技吗?

实际上,并不需要这么麻烦。

在《白话容器基础(三):深入理解容器镜像》的分享中,我已经介绍过,当容器进程被创建之后,尽管开启了Mount Namespace,但是在它执行chroot(或者pivot_root)之前,容器进程一直可以看到宿主机上的整个文件系统。

而宿主机上的文件系统,也自然包括了我们要使用的容器镜像。这个镜像的各个层,保存在/var/lib/docker/aufs/diff目录下,在容器进程启动后,它们会被联合挂载在/var/lib/docker/aufs/mnt/目录中,这样容器所需的rootfs就准备好了。

所以,我们只需要在rootfs准备好之后,在执行chroot之前,把Volume指定的宿主机目录(比如/home目录),挂载到指定的容器目录(比如/test目录)在宿主机上对应的目录(即/var/lib/docker/aufs/mnt/[可读写层ID]/test)上,这个Volume的挂载工作就完成了。

更重要的是,由于执行这个挂载操作时,“容器进程”已经创建了,也就意味着此时Mount Namespace已经开启了。所以,这个挂载事件只在这个容器里可见。你在宿主机上,是看不见容器内部的这个挂载点的。这就保证了容器的隔离性不会被Volume打破

注意:这里提到的"容器进程",是Docker创建的一个容器初始化进程(dockerinit),而不是应用进程(ENTRYPOINT + CMD)。dockerinit会负责完成根目录的准备、挂载设备和目录、配置hostname等一系列需要在容器内进行的初始化操作。最后,它通过execv()系统调用,让应用进程取代自己,成为容器里的PID=1的进程。

而这里要使用到的挂载技术,就是Linux的绑定挂载(bind mount)机制。它的主要作用就是,允许你将一个目录或者文件,而不是整个设备,挂载到一个指定的目录上。并且,这时你在该挂载点上进行的任何操作,只是发生在被挂载的目录或者文件上,而原挂载点的内容则会被隐藏起来且不受影响。

其实,如果你了解Linux 内核的话,就会明白,绑定挂载实际上是一个inode替换的过程。在Linux操作系统中,inode可以理解为存放文件内容的“对象”,而dentry,也叫目录项,就是访问这个inode所使用的“指针”。


正如上图所示,mount --bind /home /test,会将/home挂载到/test上。其实相当于将/test的dentry,重定向到了/home的inode。这样当我们修改/test目录时,实际修改的是/home目录的inode。这也就是为何,一旦执行umount命令,/test目录原先的内容就会恢复:因为修改真正发生在的,是/home目录里。

所以,在一个正确的时机,进行一次绑定挂载,Docker就可以成功地将一个宿主机上的目录或文件,不动声色地挂载到容器中。

这样,进程在容器里对这个/test目录进行的所有操作,都实际发生在宿主机的对应目录(比如,/home,或者/var/lib/docker/volumes/[VOLUME_ID]/_data)里,而不会影响容器镜像的内容。

那么,这个/test目录里的内容,既然挂载在容器rootfs的可读写层,它会不会被docker commit提交掉呢?

也不会。

这个原因其实我们前面已经提到过。容器的镜像操作,比如docker commit,都是发生在宿主机空间的。而由于Mount Namespace的隔离作用,宿主机并不知道这个绑定挂载的存在。所以,在宿主机看来,容器中可读写层的/test目录(/var/lib/docker/aufs/mnt/[可读写层ID]/test),始终是空的。

不过,由于Docker一开始还是要创建/test这个目录作为挂载点,所以执行了docker commit之后,你会发现新产生的镜像里,会多出来一个空的/test目录。毕竟,新建目录操作,又不是挂载操作,Mount Namespace对它可起不到“障眼法”的作用。

结合以上的讲解,我们现在来亲自验证一下:

首先,启动一个helloworld容器,给它声明一个Volume,挂载在容器里的/test目录上:

$ docker run -d -v /test helloworld
cf53b766fa6f

容器启动之后,我们来查看一下这个Volume的ID:

$ docker volume ls
DRIVER              VOLUME NAME
local               cb1c2f7221fa9b0971cc35f68aa1034824755ac44a034c0c0a1dd318838d3a6d

然后,使用这个ID,可以找到它在Docker工作目录下的volumes路径:

$ ls /var/lib/docker/volumes/cb1c2f7221fa/_data/

这个_data文件夹,就是这个容器的Volume在宿主机上对应的临时目录了。

接下来,我们在容器的Volume里,添加一个文件text.txt:

$ docker exec -it cf53b766fa6f /bin/sh
cd test/
touch text.txt

这时,我们再回到宿主机,就会发现text.txt已经出现在了宿主机上对应的临时目录里:

$ ls /var/lib/docker/volumes/cb1c2f7221fa/_data/
text.txt

可是,如果你在宿主机上查看该容器的可读写层,虽然可以看到这个/test目录,但其内容是空的(关于如何找到这个AuFS文件系统的路径,请参考我上一次分享的内容):

$ ls /var/lib/docker/aufs/mnt/6780d0778b8a/test

可以确认,容器Volume里的信息,并不会被docker commit提交掉;但这个挂载点目录/test本身,则会出现在新的镜像当中。

以上内容,就是Docker Volume的核心原理了。

总结

在今天的这次分享中,我用了一个非常经典的Python应用作为案例,讲解了Docke容器使用的主要场景。熟悉了这些操作,你也就基本上摸清了Docker容器的核心功能。

更重要的是,我着重介绍了如何使用Linux Namespace、Cgroups,以及rootfs的知识,对容器进行了一次庖丁解牛似的解读。

借助这种思考问题的方法,最后的Docker容器,我们实际上就可以用下面这个“全景图”描述出来:

这个容器进程“python app.py”,运行在由Linux Namespace和Cgroups构成的隔离环境里;而它运行所需要的各种文件,比如python,app.py,以及整个操作系统文件,则由多个联合挂载在一起的rootfs层提供。

这些rootfs层的最下层,是来自Docker镜像的只读层。

在只读层之上,是Docker自己添加的Init层,用来存放被临时修改过的/etc/hosts等文件。

而rootfs的最上层是一个可读写层,它以Copy-on-Write的方式存放任何对只读层的修改,容器声明的Volume的挂载点,也出现在这一层。

通过这样的剖析,对于曾经“神秘莫测”的容器技术,你是不是感觉清晰了很多呢?

思考题

  1. 你在查看Docker容器的Namespace时,是否注意到有一个叫cgroup的Namespace?它是Linux 4.6之后新增加的一个Namespace,你知道它的作用吗?

  2. 如果你执行docker run -v /home:/test的时候,容器镜像里的/test目录下本来就有内容的话,你会发现,在宿主机的/home目录下,也会出现这些内容。这是怎么回事?为什么它们没有被绑定挂载隐藏起来呢?(提示:Docker的“copyData”功能)

  3. 请尝试给这个Python应用加上CPU和Memory限制,然后启动它。根据我们前面介绍的Cgroups的知识,请你查看一下这个容器的Cgroups文件系统的设置,是不是跟我前面的讲解一致。

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

09-从容器到容器云:谈谈Kubernetes的本质

你好,我是张磊。今天我和你分享的主题是:从容器到容器云,谈谈Kubernetes的本质。

在前面的四篇文章中,我以Docker项目为例,一步步剖析了Linux容器的具体实现方式。通过这些讲解你应该能够明白:一个“容器”,实际上是一个由Linux Namespace、Linux Cgroups和rootfs三种技术构建出来的进程的隔离环境。

从这个结构中我们不难看出,一个正在运行的Linux容器,其实可以被“一分为二”地看待:

  1. 一组联合挂载在/var/lib/docker/aufs/mnt上的rootfs,这一部分我们称为“容器镜像”(Container Image),是容器的静态视图;

  2. 一个由Namespace+Cgroups构成的隔离环境,这一部分我们称为“容器运行时”(Container Runtime),是容器的动态视图。

更进一步地说,作为一名开发者,我并不关心容器运行时的差异。因为,在整个“开发-测试-发布”的流程中,真正承载着容器信息进行传递的,是容器镜像,而不是容器运行时。

这个重要假设,正是容器技术圈在Docker项目成功后不久,就迅速走向了“容器编排”这个“上层建筑”的主要原因:作为一家云服务商或者基础设施提供商,我只要能够将用户提交的Docker镜像以容器的方式运行起来,就能成为这个非常热闹的容器生态图上的一个承载点,从而将整个容器技术栈上的价值,沉淀在我的这个节点上。

更重要的是,只要从我这个承载点向Docker镜像制作者和使用者方向回溯,整条路径上的各个服务节点,比如CI/CD、监控、安全、网络、存储等等,都有我可以发挥和盈利的余地。这个逻辑,正是所有云计算提供商如此热衷于容器技术的重要原因:通过容器镜像,它们可以和潜在用户(即,开发者)直接关联起来。

从一个开发者和单一的容器镜像,到无数开发者和庞大的容器集群,容器技术实现了从“容器”到“容器云”的飞跃,标志着它真正得到了市场和生态的认可。

这样,容器就从一个开发者手里的小工具,一跃成为了云计算领域的绝对主角;而能够定义容器组织和管理规范的“容器编排”技术,则当仁不让地坐上了容器技术领域的“头把交椅”。

这其中,最具代表性的容器编排工具,当属Docker公司的Compose+Swarm组合,以及Google与RedHat公司共同主导的Kubernetes项目。

我在前面介绍容器技术发展历史的四篇预习文章中,已经对这两个开源项目做了详细的剖析和评述。所以,在今天的这次分享中,我会专注于本专栏的主角Kubernetes项目,谈一谈它的设计与架构。

跟很多基础设施领域先有工程实践、后有方法论的发展路线不同,Kubernetes项目的理论基础则要比工程实践走得靠前得多,这当然要归功于Google公司在2015年4月发布的Borg论文了。

Borg系统,一直以来都被誉为Google公司内部最强大的“秘密武器”。虽然略显夸张,但这个说法倒不算是吹牛。

因为,相比于Spanner、BigTable等相对上层的项目,Borg要承担的责任,是承载Google公司整个基础设施的核心依赖。在Google公司已经公开发表的基础设施体系论文中,Borg项目当仁不让地位居整个基础设施技术栈的最底层。


图片来源:Malte Schwarzkopf. “Operating system support for warehouse-scale computing”. PhD thesis. University of Cambridge Computer Laboratory (to appear), 2015, Chapter 2.

上面这幅图,来自于Google Omega论文的第一作者的博士毕业论文。它描绘了当时Google已经公开发表的整个基础设施栈。在这个图里,你既可以找到MapReduce、BigTable等知名项目,也能看到Borg和它的继任者Omega位于整个技术栈的最底层。

正是由于这样的定位,Borg可以说是Google最不可能开源的一个项目。而幸运的是,得益于Docker项目和容器技术的风靡,它却终于得以以另一种方式与开源社区见面,这个方式就是Kubernetes项目。

所以,相比于“小打小闹”的Docker公司、“旧瓶装新酒”的Mesos社区,Kubernetes项目从一开始就比较幸运地站上了一个他人难以企及的高度:在它的成长阶段,这个项目每一个核心特性的提出,几乎都脱胎于Borg/Omega系统的设计与经验。更重要的是,这些特性在开源社区落地的过程中,又在整个社区的合力之下得到了极大的改进,修复了很多当年遗留在Borg体系中的缺陷和问题。

所以,尽管在发布之初被批评是“曲高和寡”,但是在逐渐觉察到Docker技术栈的“稚嫩”和Mesos社区的“老迈”之后,这个社区很快就明白了:Kubernetes项目在Borg体系的指导下,体现出了一种独有的“先进性”与“完备性”,而这些特质才是一个基础设施领域开源项目赖以生存的核心价值。

为了更好地理解这两种特质,我们不妨从Kubernetes的顶层设计说起。

首先,Kubernetes项目要解决的问题是什么?

编排?调度?容器云?还是集群管理?

实际上,这个问题到目前为止都没有固定的答案。因为在不同的发展阶段,Kubernetes需要着重解决的问题是不同的。

但是,对于大多数用户来说,他们希望Kubernetes项目带来的体验是确定的:现在我有了应用的容器镜像,请帮我在一个给定的集群上把这个应用运行起来。

更进一步地说,我还希望Kubernetes能给我提供路由网关、水平扩展、监控、备份、灾难恢复等一系列运维能力。

等一下,这些功能听起来好像有些耳熟?这不就是经典PaaS(比如,Cloud Foundry)项目的能力吗?

而且,有了Docker之后,我根本不需要什么Kubernetes、PaaS,只要使用Docker公司的Compose+Swarm项目,就完全可以很方便地DIY出这些功能了!

所以说,如果Kubernetes项目只是停留在拉取用户镜像、运行容器,以及提供常见的运维功能的话,那么别说跟“原生”的Docker Swarm项目竞争了,哪怕跟经典的PaaS项目相比也难有什么优势可言。

而实际上,在定义核心功能的过程中,Kubernetes项目正是依托着Borg项目的理论优势,才在短短几个月内迅速站稳了脚跟,进而确定了一个如下图所示的全局架构:

我们可以看到,Kubernetes项目的架构,跟它的原型项目Borg非常类似,都由Master和Node两种节点组成,而这两种角色分别对应着控制节点和计算节点。

其中,控制节点,即Master节点,由三个紧密协作的独立组件组合而成,它们分别是负责API服务的kube-apiserver、负责调度的kube-scheduler,以及负责容器编排的kube-controller-manager。整个集群的持久化数据,则由kube-apiserver处理后保存在Etcd中。

而计算节点上最核心的部分,则是一个叫作kubelet的组件。

在Kubernetes项目中,kubelet主要负责同容器运行时(比如Docker项目)打交道。而这个交互所依赖的,是一个称作CRI(Container Runtime Interface)的远程调用接口,这个接口定义了容器运行时的各项核心操作,比如:启动一个容器需要的所有参数。

这也是为何,Kubernetes项目并不关心你部署的是什么容器运行时、使用的什么技术实现,只要你的这个容器运行时能够运行标准的容器镜像,它就可以通过实现CRI接入到Kubernetes项目当中。

而具体的容器运行时,比如Docker项目,则一般通过OCI这个容器运行时规范同底层的Linux操作系统进行交互,即:把CRI请求翻译成对Linux操作系统的调用(操作Linux Namespace和Cgroups等)。

此外,kubelet还通过gRPC协议同一个叫作Device Plugin的插件进行交互。这个插件,是Kubernetes项目用来管理GPU等宿主机物理设备的主要组件,也是基于Kubernetes项目进行机器学习训练、高性能作业支持等工作必须关注的功能。

kubelet的另一个重要功能,则是调用网络插件和存储插件为容器配置网络和持久化存储。这两个插件与kubelet进行交互的接口,分别是CNI(Container Networking Interface)和CSI(Container Storage Interface)。

实际上,kubelet这个奇怪的名字,来自于Borg项目里的同源组件Borglet。不过,如果你浏览过Borg论文的话,就会发现,这个命名方式可能是kubelet组件与Borglet组件的唯一相似之处。因为Borg项目,并不支持我们这里所讲的容器技术,而只是简单地使用了Linux Cgroups对进程进行限制。

这就意味着,像Docker这样的“容器镜像”在Borg中是不存在的,Borglet组件也自然不需要像kubelet这样考虑如何同Docker进行交互、如何对容器镜像进行管理的问题,也不需要支持CRI、CNI、CSI等诸多容器技术接口。

可以说,kubelet完全就是为了实现Kubernetes项目对容器的管理能力而重新实现的一个组件,与Borg之间并没有直接的传承关系。

备注:虽然不使用Docker,但Google内部确实在使用一个包管理工具,名叫Midas Package Manager (MPM),其实它可以部分取代Docker镜像的角色。

那么,Borg对于Kubernetes项目的指导作用又体现在哪里呢?

答案是,Master节点。

虽然在Master节点的实现细节上Borg项目与Kubernetes项目不尽相同,但它们的出发点却高度一致,即:如何编排、管理、调度用户提交的作业?

所以,Borg项目完全可以把Docker镜像看作一种新的应用打包方式。这样,Borg团队过去在大规模作业管理与编排上的经验就可以直接“套”在Kubernetes项目上了。

这些经验最主要的表现就是,从一开始,Kubernetes项目就没有像同时期的各种“容器云”项目那样,把Docker作为整个架构的核心,而仅仅把它作为最底层的一个容器运行时实现。

而Kubernetes项目要着重解决的问题,则来自于Borg的研究人员在论文中提到的一个非常重要的观点:

运行在大规模集群中的各种任务之间,实际上存在着各种各样的关系。这些关系的处理,才是作业编排和管理系统最困难的地方。

事实也正是如此。

其实,这种任务与任务之间的关系,在我们平常的各种技术场景中随处可见。比如,一个Web应用与数据库之间的访问关系,一个负载均衡器和它的后端服务之间的代理关系,一个门户应用与授权组件之间的调用关系。

更进一步地说,同属于一个服务单位的不同功能之间,也完全可能存在这样的关系。比如,一个Web应用与日志搜集组件之间的文件交换关系。

而在容器技术普及之前,传统虚拟机环境对这种关系的处理方法都是比较“粗粒度”的。你会经常发现很多功能并不相关的应用被一股脑儿地部署在同一台虚拟机中,只是因为它们之间偶尔会互相发起几个HTTP请求。

更常见的情况则是,一个应用被部署在虚拟机里之后,你还得手动维护很多跟它协作的守护进程(Daemon),用来处理它的日志搜集、灾难恢复、数据备份等辅助工作。

但容器技术出现以后,你就不难发现,在“功能单位”的划分上,容器有着独一无二的“细粒度”优势:毕竟容器的本质,只是一个进程而已。

也就是说,只要你愿意,那些原先拥挤在同一个虚拟机里的各个应用、组件、守护进程,都可以被分别做成镜像,然后运行在一个个专属的容器中。它们之间互不干涉,拥有各自的资源配额,可以被调度在整个集群里的任何一台机器上。而这,正是一个PaaS系统最理想的工作状态,也是所谓“微服务”思想得以落地的先决条件。

当然,如果只做到“封装微服务、调度单容器”这一层次,Docker Swarm项目就已经绰绰有余了。如果再加上Compose项目,你甚至还具备了处理一些简单依赖关系的能力,比如:一个“Web容器”和它要访问的数据库“DB容器”。

在Compose项目中,你可以为这样的两个容器定义一个“link”,而Docker项目则会负责维护这个“link”关系,其具体做法是:Docker会在Web容器中,将DB容器的IP地址、端口等信息以环境变量的方式注入进去,供应用进程使用,比如:

    DB_NAME=/web/db
    DB_PORT=tcp://172.17.0.5:5432
    DB_PORT_5432_TCP=tcp://172.17.0.5:5432
    DB_PORT_5432_TCP_PROTO=tcp
    DB_PORT_5432_TCP_PORT=5432
    DB_PORT_5432_TCP_ADDR=172.17.0.5

而当DB容器发生变化时(比如,镜像更新,被迁移到其他宿主机上等等),这些环境变量的值会由Docker项目自动更新。这就是平台项目自动地处理容器间关系的典型例子。

可是,如果我们现在的需求是,要求这个项目能够处理前面提到的所有类型的关系,甚至还要能够支持未来可能出现的更多种类的关系呢?

这时,“link”这种单独针对一种案例设计的解决方案就太过简单了。如果你做过架构方面的工作,就会深有感触:一旦要追求项目的普适性,那就一定要从顶层开始做好设计。

所以,Kubernetes项目最主要的设计思想是,从更宏观的角度,以统一的方式来定义任务之间的各种关系,并且为将来支持更多种类的关系留有余地。

比如,Kubernetes项目对容器间的“访问”进行了分类,首先总结出了一类非常常见的“紧密交互”的关系,即:这些应用之间需要非常频繁的交互和访问;又或者,它们会直接通过本地文件进行信息交换。

在常规环境下,这些应用往往会被直接部署在同一台机器上,通过Localhost通信,通过本地磁盘目录交换文件。而在Kubernetes项目中,这些容器则会被划分为一个“Pod”,Pod里的容器共享同一个Network Namespace、同一组数据卷,从而达到高效率交换信息的目的。

Pod是Kubernetes项目中最基础的一个对象,源自于Google Borg论文中一个名叫Alloc的设计。在后续的章节中,我们会对Pod做更进一步地阐述。

而对于另外一种更为常见的需求,比如Web应用与数据库之间的访问关系,Kubernetes项目则提供了一种叫作“Service”的服务。像这样的两个应用,往往故意不部署在同一台机器上,这样即使Web应用所在的机器宕机了,数据库也完全不受影响。可是,我们知道,对于一个容器来说,它的IP地址等信息不是固定的,那么Web应用又怎么找到数据库容器的Pod呢?

所以,Kubernetes项目的做法是给Pod绑定一个Service服务,而Service服务声明的IP地址等信息是“终生不变”的。这个Service服务的主要作用,就是作为Pod的代理入口(Portal),从而代替Pod对外暴露一个固定的网络地址

这样,对于Web应用的Pod来说,它需要关心的就是数据库Pod的Service信息。不难想象,Service后端真正代理的Pod的IP地址、端口等信息的自动更新、维护,则是Kubernetes项目的职责。

像这样,围绕着容器和Pod不断向真实的技术场景扩展,我们就能够摸索出一幅如下所示的Kubernetes项目核心功能的“全景图”。

按照这幅图的线索,我们从容器这个最基础的概念出发,首先遇到了容器间“紧密协作”关系的难题,于是就扩展到了Pod;有了Pod之后,我们希望能一次启动多个应用的实例,这样就需要Deployment这个Pod的多实例管理器;而有了这样一组相同的Pod后,我们又需要通过一个固定的IP地址和端口以负载均衡的方式访问它,于是就有了Service。

可是,如果现在两个不同Pod之间不仅有“访问关系”,还要求在发起时加上授权信息。最典型的例子就是Web应用对数据库访问时需要Credential(数据库的用户名和密码)信息。那么,在Kubernetes中这样的关系又如何处理呢?

Kubernetes项目提供了一种叫作Secret的对象,它其实是一个保存在Etcd里的键值对数据。这样,你把Credential信息以Secret的方式存在Etcd里,Kubernetes就会在你指定的Pod(比如,Web应用的Pod)启动时,自动把Secret里的数据以Volume的方式挂载到容器里。这样,这个Web应用就可以访问数据库了。

除了应用与应用之间的关系外,应用运行的形态是影响“如何容器化这个应用”的第二个重要因素。

为此,Kubernetes定义了新的、基于Pod改进后的对象。比如Job,用来描述一次性运行的Pod(比如,大数据任务);再比如DaemonSet,用来描述每个宿主机上必须且只能运行一个副本的守护进程服务;又比如CronJob,则用于描述定时任务等等。

如此种种,正是Kubernetes项目定义容器间关系和形态的主要方法。

可以看到,Kubernetes项目并没有像其他项目那样,为每一个管理功能创建一个指令,然后在项目中实现其中的逻辑。这种做法,的确可以解决当前的问题,但是在更多的问题来临之后,往往会力不从心。

相比之下,在Kubernetes项目中,我们所推崇的使用方法是:

  • 首先,通过一个“编排对象”,比如Pod、Job、CronJob等,来描述你试图管理的应用;
  • 然后,再为它定义一些“服务对象”,比如Service、Secret、Horizontal Pod Autoscaler(自动水平扩展器)等。这些对象,会负责具体的平台级功能。

这种使用方法,就是所谓的“声明式API”。这种API对应的“编排对象”和“服务对象”,都是Kubernetes项目中的API对象(API Object)。

这就是Kubernetes最核心的设计理念,也是接下来我会重点剖析的关键技术点。

最后,我来回答一个更直接的问题:Kubernetes项目如何启动一个容器化任务呢?

比如,我现在已经制作好了一个Nginx容器镜像,希望让平台帮我启动这个镜像。并且,我要求平台帮我运行两个完全相同的Nginx副本,以负载均衡的方式共同对外提供服务。

  • 如果是自己DIY的话,可能需要启动两台虚拟机,分别安装两个Nginx,然后使用keepalived为这两个虚拟机做一个虚拟IP。

  • 而如果使用Kubernetes项目呢?你需要做的则是编写如下这样一个YAML文件(比如名叫nginx-deployment.yaml):

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
  labels:
    app: nginx
spec:
  replicas: 2
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.7.9
        ports:
        - containerPort: 80

在上面这个YAML文件中,我们定义了一个Deployment对象,它的主体部分(spec.template部分)是一个使用Nginx镜像的Pod,而这个Pod的副本数是2(replicas=2)。

然后执行:

$ kubectl create -f nginx-deployment.yaml

这样,两个完全相同的Nginx容器副本就被启动了。

不过,这么看来,做同样一件事情,Kubernetes用户要做的工作也不少嘛。

别急,在后续的讲解中,我会陆续介绍Kubernetes项目这种“声明式API”的种种好处,以及基于它实现的强大的编排能力。

拭目以待吧。

总结

首先,我和你一起回顾了容器的核心知识,说明了容器其实可以分为两个部分:容器运行时和容器镜像。

然后,我重点介绍了Kubernetes项目的架构,详细讲解了它如何使用“声明式API”来描述容器化业务和容器间关系的设计思想。

实际上,过去很多的集群管理项目(比如Yarn、Mesos,以及Swarm)所擅长的,都是把一个容器,按照某种规则,放置在某个最佳节点上运行起来。这种功能,我们称为“调度”。

而Kubernetes项目所擅长的,是按照用户的意愿和整个系统的规则,完全自动化地处理好容器之间的各种关系。这种功能,就是我们经常听到的一个概念:编排。

所以说,Kubernetes项目的本质,是为用户提供一个具有普遍意义的容器编排工具。

不过,更重要的是,Kubernetes项目为用户提供的不仅限于一个工具。它真正的价值,乃在于提供了一套基于容器构建分布式系统的基础依赖。关于这一点,相信你会在今后的学习中,体会越来越深。

思考题

  1. 这今天的分享中,我介绍了Kubernetes项目的架构。你是否了解了Docker Swarm(SwarmKit项目)和Kubernetes在架构上和使用方法上的异同呢?

  2. 在Kubernetes之前,很多项目都没办法管理“有状态”的容器,即,不能从一台宿主机“迁移”到另一台宿主机上的容器。你是否能列举出,阻止这种“迁移”的原因都有哪些呢?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

10-Kubernetes一键部署利器:kubeadm

你好,我是张磊。今天我和你分享的主题是:Kubernetes一键部署利器之kubeadm。

通过前面几篇文章的内容,我其实阐述了这样一个思想:要真正发挥容器技术的实力,你就不能仅仅局限于对Linux容器本身的钻研和使用。

这些知识更适合作为你的技术储备,以便在需要的时候可以帮你更快地定位问题,并解决问题。

而更深入地学习容器技术的关键在于,如何使用这些技术来“容器化”你的应用。

比如,我们的应用既可能是Java Web和MySQL这样的组合,也可能是Cassandra这样的分布式系统。而要使用容器把后者运行起来,你单单通过Docker把一个Cassandra镜像跑起来是没用的。

要把Cassandra应用容器化的关键,在于如何处理好这些Cassandra容器之间的编排关系。比如,哪些Cassandra容器是主,哪些是从?主从容器如何区分?它们之间又如何进行自动发现和通信?Cassandra容器的持久化数据又如何保持,等等。

这也是为什么我们要反复强调Kubernetes项目的主要原因:这个项目体现出来的容器化“表达能力”,具有独有的先进性和完备性。这就使得它不仅能运行Java Web与MySQL这样的常规组合,还能够处理Cassandra容器集群等复杂编排问题。所以,对这种编排能力的剖析、解读和最佳实践,将是本专栏最重要的一部分内容。

不过,万事开头难。

作为一个典型的分布式项目,Kubernetes的部署一直以来都是挡在初学者前面的一只“拦路虎”。尤其是在Kubernetes项目发布初期,它的部署完全要依靠一堆由社区维护的脚本。

其实,Kubernetes作为一个Golang项目,已经免去了很多类似于Python项目要安装语言级别依赖的麻烦。但是,除了将各个组件编译成二进制文件外,用户还要负责为这些二进制文件编写对应的配置文件、配置自启动脚本,以及为kube-apiserver配置授权文件等等诸多运维工作。

目前,各大云厂商最常用的部署的方法,是使用SaltStack、Ansible等运维工具自动化地执行这些步骤。

但即使这样,这个部署过程依然非常繁琐。因为,SaltStack这类专业运维工具本身的学习成本,就可能比Kubernetes项目还要高。

难道Kubernetes项目就没有简单的部署方法了吗?

这个问题,在Kubernetes社区里一直没有得到足够重视。直到2017年,在志愿者的推动下,社区才终于发起了一个独立的部署工具,名叫:kubeadm

这个项目的目的,就是要让用户能够通过这样两条指令完成一个Kubernetes集群的部署:

# 创建一个Master节点
$ kubeadm init

# 将一个Node节点加入到当前集群中
$ kubeadm join <Master节点的IP和端口>

是不是非常方便呢?

不过,你可能也会有所顾虑:Kubernetes的功能那么多,这样一键部署出来的集群,能用于生产环境吗?

为了回答这个问题,在今天这篇文章,我就先和你介绍一下kubeadm的工作原理吧。

kubeadm的工作原理

在上一篇文章《从容器到容器云:谈谈Kubernetes的本质》中,我已经详细介绍了Kubernetes的架构和它的组件。在部署时,它的每一个组件都是一个需要被执行的、单独的二进制文件。所以不难想象,SaltStack这样的运维工具或者由社区维护的脚本的功能,就是要把这些二进制文件传输到指定的机器当中,然后编写控制脚本来启停这些组件。

不过,在理解了容器技术之后,你可能已经萌生出了这样一个想法,为什么不用容器部署Kubernetes呢?

这样,我只要给每个Kubernetes组件做一个容器镜像,然后在每台宿主机上用docker run指令启动这些组件容器,部署不就完成了吗?

事实上,在Kubernetes早期的部署脚本里,确实有一个脚本就是用Docker部署Kubernetes项目的,这个脚本相比于SaltStack等的部署方式,也的确简单了不少。

但是,这样做会带来一个很麻烦的问题,即:如何容器化kubelet。

我在上一篇文章中,已经提到kubelet是Kubernetes项目用来操作Docker等容器运行时的核心组件。可是,除了跟容器运行时打交道外,kubelet在配置容器网络、管理容器数据卷时,都需要直接操作宿主机。

而如果现在kubelet本身就运行在一个容器里,那么直接操作宿主机就会变得很麻烦。对于网络配置来说还好,kubelet容器可以通过不开启Network Namespace(即Docker的host network模式)的方式,直接共享宿主机的网络栈。可是,要让kubelet隔着容器的Mount Namespace和文件系统,操作宿主机的文件系统,就有点儿困难了。

比如,如果用户想要使用NFS做容器的持久化数据卷,那么kubelet就需要在容器进行绑定挂载前,在宿主机的指定目录上,先挂载NFS的远程目录。

可是,这时候问题来了。由于现在kubelet是运行在容器里的,这就意味着它要做的这个“mount -F nfs”命令,被隔离在了一个单独的Mount Namespace中。即,kubelet做的挂载操作,不能被“传播”到宿主机上。

对于这个问题,有人说,可以使用setns()系统调用,在宿主机的Mount Namespace中执行这些挂载操作;也有人说,应该让Docker支持一个–mnt=host的参数。

但是,到目前为止,在容器里运行kubelet,依然没有很好的解决办法,我也不推荐你用容器去部署Kubernetes项目。

正因为如此,kubeadm选择了一种妥协方案:

把kubelet直接运行在宿主机上,然后使用容器部署其他的Kubernetes组件。

所以,你使用kubeadm的第一步,是在机器上手动安装kubeadm、kubelet和kubectl这三个二进制文件。当然,kubeadm的作者已经为各个发行版的Linux准备好了安装包,所以你只需要执行:

$ apt-get install kubeadm

就可以了。

接下来,你就可以使用“kubeadm init”部署Master节点了。

kubeadm init的工作流程

当你执行kubeadm init指令后,kubeadm首先要做的,是一系列的检查工作,以确定这台机器可以用来部署Kubernetes。这一步检查,我们称为“Preflight Checks”,它可以为你省掉很多后续的麻烦。

其实,Preflight Checks包括了很多方面,比如:

  • Linux内核的版本必须是否是3.10以上?
  • Linux Cgroups模块是否可用?
  • 机器的hostname是否标准?在Kubernetes项目里,机器的名字以及一切存储在Etcd中的API对象,都必须使用标准的DNS命名(RFC 1123)。
  • 用户安装的kubeadm和kubelet的版本是否匹配?
  • 机器上是不是已经安装了Kubernetes的二进制文件?
  • Kubernetes的工作端口10250/10251/10252端口是不是已经被占用?
  • ip、mount等Linux指令是否存在?
  • Docker是否已经安装?
  • ……

在通过了Preflight Checks之后,kubeadm要为你做的,是生成Kubernetes对外提供服务所需的各种证书和对应的目录。

Kubernetes对外提供服务时,除非专门开启“不安全模式”,否则都要通过HTTPS才能访问kube-apiserver。这就需要为Kubernetes集群配置好证书文件。

kubeadm为Kubernetes项目生成的证书文件都放在Master节点的/etc/kubernetes/pki目录下。在这个目录下,最主要的证书文件是ca.crt和对应的私钥ca.key。

此外,用户使用kubectl获取容器日志等streaming操作时,需要通过kube-apiserver向kubelet发起请求,这个连接也必须是安全的。kubeadm为这一步生成的是apiserver-kubelet-client.crt文件,对应的私钥是apiserver-kubelet-client.key。

除此之外,Kubernetes集群中还有Aggregate APIServer等特性,也需要用到专门的证书,这里我就不再一一列举了。需要指出的是,你可以选择不让kubeadm为你生成这些证书,而是拷贝现有的证书到如下证书的目录里:

/etc/kubernetes/pki/ca.{crt,key}

这时,kubeadm就会跳过证书生成的步骤,把它完全交给用户处理。

证书生成后,kubeadm接下来会为其他组件生成访问kube-apiserver所需的配置文件。这些文件的路径是:/etc/kubernetes/xxx.conf:

ls /etc/kubernetes/
admin.conf  controller-manager.conf  kubelet.conf  scheduler.conf

这些文件里面记录的是,当前这个Master节点的服务器地址、监听端口、证书目录等信息。这样,对应的客户端(比如scheduler,kubelet等),可以直接加载相应的文件,使用里面的信息与kube-apiserver建立安全连接。

接下来,kubeadm会为Master组件生成Pod配置文件。我已经在上一篇文章中和你介绍过Kubernetes有三个Master组件kube-apiserver、kube-controller-manager、kube-scheduler,而它们都会被使用Pod的方式部署起来。

你可能会有些疑问:这时,Kubernetes集群尚不存在,难道kubeadm会直接执行docker run来启动这些容器吗?

当然不是。

在Kubernetes中,有一种特殊的容器启动方法叫做“Static Pod”。它允许你把要部署的Pod的YAML文件放在一个指定的目录里。这样,当这台机器上的kubelet启动时,它会自动检查这个目录,加载所有的Pod YAML文件,然后在这台机器上启动它们。

从这一点也可以看出,kubelet在Kubernetes项目中的地位非常高,在设计上它就是一个完全独立的组件,而其他Master组件,则更像是辅助性的系统容器。

在kubeadm中,Master组件的YAML文件会被生成在/etc/kubernetes/manifests路径下。比如,kube-apiserver.yaml:

apiVersion: v1
kind: Pod
metadata:
  annotations:
    scheduler.alpha.kubernetes.io/critical-pod: ""
  creationTimestamp: null
  labels:
    component: kube-apiserver
    tier: control-plane
  name: kube-apiserver
  namespace: kube-system
spec:
  containers:
  - command:
    - kube-apiserver
    - --authorization-mode=Node,RBAC
    - --runtime-config=api/all=true
    - --advertise-address=10.168.0.2
    ...
    - --tls-cert-file=/etc/kubernetes/pki/apiserver.crt
    - --tls-private-key-file=/etc/kubernetes/pki/apiserver.key
    image: k8s.gcr.io/kube-apiserver-amd64:v1.11.1
    imagePullPolicy: IfNotPresent
    livenessProbe:
      ...
    name: kube-apiserver
    resources:
      requests:
        cpu: 250m
    volumeMounts:
    - mountPath: /usr/share/ca-certificates
      name: usr-share-ca-certificates
      readOnly: true
    ...
  hostNetwork: true
  priorityClassName: system-cluster-critical
  volumes:
  - hostPath:
      path: /etc/ca-certificates
      type: DirectoryOrCreate
    name: etc-ca-certificates
  ...

关于一个Pod的YAML文件怎么写、里面的字段如何解读,我会在后续专门的文章中为你详细分析。在这里,你只需要关注这样几个信息:

  1. 这个Pod里只定义了一个容器,它使用的镜像是:k8s.gcr.io/kube-apiserver-amd64:v1.11.1 。这个镜像是Kubernetes官方维护的一个组件镜像。

  2. 这个容器的启动命令(commands)是kube-apiserver --authorization-mode=Node,RBAC …,这样一句非常长的命令。其实,它就是容器里kube-apiserver这个二进制文件再加上指定的配置参数而已。

  3. 如果你要修改一个已有集群的kube-apiserver的配置,需要修改这个YAML文件。

  4. 这些组件的参数也可以在部署时指定,我很快就会讲到。

在这一步完成后,kubeadm还会再生成一个Etcd的Pod YAML文件,用来通过同样的Static Pod的方式启动Etcd。所以,最后Master组件的Pod YAML文件如下所示:

$ ls /etc/kubernetes/manifests/
etcd.yaml  kube-apiserver.yaml  kube-controller-manager.yaml  kube-scheduler.yaml

而一旦这些YAML文件出现在被kubelet监视的/etc/kubernetes/manifests目录下,kubelet就会自动创建这些YAML文件中定义的Pod,即Master组件的容器。

Master容器启动后,kubeadm会通过检查localhost:6443/healthz这个Master组件的健康检查URL,等待Master组件完全运行起来。

然后,kubeadm就会为集群生成一个bootstrap token。在后面,只要持有这个token,任何一个安装了kubelet和kubadm的节点,都可以通过kubeadm join加入到这个集群当中。

这个token的值和使用方法,会在kubeadm init结束后被打印出来。

在token生成之后,kubeadm会将ca.crt等Master节点的重要信息,通过ConfigMap的方式保存在Etcd当中,供后续部署Node节点使用。这个ConfigMap的名字是cluster-info。

kubeadm init的最后一步,就是安装默认插件。Kubernetes默认kube-proxy和DNS这两个插件是必须安装的。它们分别用来提供整个集群的服务发现和DNS功能。其实,这两个插件也只是两个容器镜像而已,所以kubeadm只要用Kubernetes客户端创建两个Pod就可以了。

kubeadm join的工作流程

这个流程其实非常简单,kubeadm init生成bootstrap token之后,你就可以在任意一台安装了kubelet和kubeadm的机器上执行kubeadm join了。

可是,为什么执行kubeadm join需要这样一个token呢?

因为,任何一台机器想要成为Kubernetes集群中的一个节点,就必须在集群的kube-apiserver上注册。可是,要想跟apiserver打交道,这台机器就必须要获取到相应的证书文件(CA文件)。可是,为了能够一键安装,我们就不能让用户去Master节点上手动拷贝这些文件。

所以,kubeadm至少需要发起一次“不安全模式”的访问到kube-apiserver,从而拿到保存在ConfigMap中的cluster-info(它保存了APIServer的授权信息)。而bootstrap token,扮演的就是这个过程中的安全验证的角色。

只要有了cluster-info里的kube-apiserver的地址、端口、证书,kubelet就可以以“安全模式”连接到apiserver上,这样一个新的节点就部署完成了。

接下来,你只要在其他节点上重复这个指令就可以了。

配置kubeadm的部署参数

我在前面讲了kubeadm部署Kubernetes集群最关键的两个步骤,kubeadm init和kubeadm join。相信你一定会有这样的疑问:kubeadm确实简单易用,可是我又该如何定制我的集群组件参数呢?

比如,我要指定kube-apiserver的启动参数,该怎么办?

在这里,我强烈推荐你在使用kubeadm init部署Master节点时,使用下面这条指令:

$ kubeadm init --config kubeadm.yaml

这时,你就可以给kubeadm提供一个YAML文件(比如,kubeadm.yaml),它的内容如下所示(我仅列举了主要部分):

apiVersion: kubeadm.k8s.io/v1alpha2
kind: MasterConfiguration
kubernetesVersion: v1.11.0
api:
  advertiseAddress: 192.168.0.102
  bindPort: 6443
  ...
etcd:
  local:
    dataDir: /var/lib/etcd
    image: ""
imageRepository: k8s.gcr.io
kubeProxy:
  config:
    bindAddress: 0.0.0.0
    ...
kubeletConfiguration:
  baseConfig:
    address: 0.0.0.0
    ...
networking:
  dnsDomain: cluster.local
  podSubnet: ""
  serviceSubnet: 10.96.0.0/12
nodeRegistration:
  criSocket: /var/run/dockershim.sock
  ...

通过制定这样一个部署参数配置文件,你就可以很方便地在这个文件里填写各种自定义的部署参数了。比如,我现在要指定kube-apiserver的参数,那么我只要在这个文件里加上这样一段信息:

...
apiServerExtraArgs:
  advertise-address: 192.168.0.103
  anonymous-auth: false
  enable-admission-plugins: AlwaysPullImages,DefaultStorageClass
  audit-log-path: /home/johndoe/audit.log

然后,kubeadm就会使用上面这些信息替换/etc/kubernetes/manifests/kube-apiserver.yaml里的command字段里的参数了。

而这个YAML文件提供的可配置项远不止这些。比如,你还可以修改kubelet和kube-proxy的配置,修改Kubernetes使用的基础镜像的URL(默认的k8s.gcr.io/xxx镜像URL在国内访问是有困难的),指定自己的证书文件,指定特殊的容器运行时等等。这些配置项,就留给你在后续实践中探索了。

总结

在今天的这次分享中,我重点介绍了kubeadm这个部署工具的工作原理和使用方法。紧接着,我会在下一篇文章中,使用它一步步地部署一个完整的Kubernetes集群。

从今天的分享中,你可以看到,kubeadm的设计非常简洁。并且,它在实现每一步部署功能时,都在最大程度地重用Kubernetes已有的功能,这也就使得我们在使用kubeadm部署Kubernetes项目时,非常有“原生”的感觉,一点都不会感到突兀。

而kubeadm的源代码,直接就在kubernetes/cmd/kubeadm目录下,是Kubernetes项目的一部分。其中,app/phases文件夹下的代码,对应的就是我在这篇文章中详细介绍的每一个具体步骤。

看到这里,你可能会猜想,kubeadm的作者一定是Google公司的某个“大神”吧。

实际上,kubeadm几乎完全是一位高中生的作品。他叫Lucas Käldström,芬兰人,今年只有18岁。kubeadm,是他17岁时用业余时间完成的一个社区项目。

所以说,开源社区的魅力也在于此:一个成功的开源项目,总能够吸引到全世界最厉害的贡献者参与其中。尽管参与者的总体水平参差不齐,而且频繁的开源活动又显得杂乱无章难以管控,但一个有足够热度的社区最终的收敛方向,却一定是代码越来越完善、Bug越来越少、功能越来越强大。

最后,我再来回答一下我在今天这次分享开始提到的问题:kubeadm能够用于生产环境吗?

到目前为止(2018年9月),这个问题的答案是:不能。

因为kubeadm目前最欠缺的是,一键部署一个高可用的Kubernetes集群,即:Etcd、Master组件都应该是多节点集群,而不是现在这样的单点。这,当然也正是kubeadm接下来发展的主要方向。

另一方面,Lucas也正在积极地把kubeadm phases开放给用户,即:用户可以更加自由地定制kubeadm的每一个部署步骤。这些举措,都可以让这个项目更加完善,我对它的发展走向也充满了信心。

当然,如果你有部署规模化生产环境的需求,我推荐使用kops或者SaltStack这样更复杂的部署工具。但,在本专栏接下来的讲解中,我都会以kubeadm为依据进行讲述。

  • 一方面,作为Kubernetes项目的原生部署工具,kubeadm对Kubernetes项目特性的使用和集成,确实要比其他项目“技高一筹”,非常值得我们学习和借鉴;
  • 另一方面,kubeadm的部署方法,不会涉及到太多的运维工作,也不需要我们额外学习复杂的部署工具。而它部署的Kubernetes集群,跟一个完全使用二进制文件搭建起来的集群几乎没有任何区别。

因此,使用kubeadm去部署一个Kubernetes集群,对于你理解Kubernetes组件的工作方式和架构,最好不过了。

思考题

  1. 在Linux上为一个类似kube-apiserver的Web Server制作证书,你知道可以用哪些工具实现吗?

  2. 回忆一下我在前面文章中分享的Kubernetes架构,你能够说出Kubernetes各个功能组件之间(包含Etcd),都有哪些建立连接或者调用的方式吗?(比如:HTTP/HTTPS,远程调用等等)

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

11-从0到1:搭建一个完整的Kubernetes集群

你好,我是张磊。今天我和你分享的主题是:从0到1搭建一个完整的Kubernetes集群。

不过,首先需要指出的是,本篇搭建指南是完全的手工操作,细节比较多,并且有些外部链接可能还会遇到特殊的“网络问题”。所以,对于只关心学习 Kubernetes 本身知识点、不太关注如何手工部署 Kubernetes 集群的同学,可以略过本节,直接使用 MiniKube 或者 Kind,来在本地启动简单的 Kubernetes 集群进行后面的学习即可。如果是使用 MiniKube 的话,阿里云还维护了一个国内版的 MiniKube,这对于在国内的同学来说会比较友好。

在上一篇文章中,我介绍了kubeadm这个Kubernetes半官方管理工具的工作原理。既然kubeadm的初衷是让Kubernetes集群的部署不再让人头疼,那么这篇文章,我们就来使用它部署一个完整的Kubernetes集群吧。

备注:这里所说的“完整”,指的是这个集群具备Kubernetes项目在GitHub上已经发布的所有功能,并能够模拟生产环境的所有使用需求。但并不代表这个集群是生产级别可用的:类似于高可用、授权、多租户、灾难备份等生产级别集群的功能暂时不在本篇文章的讨论范围。
目前,kubeadm的高可用部署已经有了第一个发布。但是,这个特性还没有GA(生产可用),所以包括了大量的手动工作,跟我们所预期的一键部署还有一定距离。GA的日期预计是2018年底到2019年初。届时,如果有机会我会再和你分享这部分内容。

这次部署,我不会依赖于任何公有云或私有云的能力,而是完全在Bare-metal环境中完成。这样的部署经验会更有普适性。而在后续的讲解中,如非特殊强调,我也都会以本次搭建的这个集群为基础。

准备工作

首先,准备机器。最直接的办法,自然是到公有云上申请几个虚拟机。当然,如果条件允许的话,拿几台本地的物理服务器来组集群是最好不过了。这些机器只要满足如下几个条件即可:

  1. 满足安装Docker项目所需的要求,比如64位的Linux操作系统、3.10及以上的内核版本;

  2. x86或者ARM架构均可;

  3. 机器之间网络互通,这是将来容器之间网络互通的前提;

  4. 有外网访问权限,因为需要拉取镜像;

  5. 能够访问到gcr.io、quay.io这两个docker registry,因为有小部分镜像需要在这里拉取;

  6. 单机可用资源建议2核CPU、8 GB内存或以上,再小的话问题也不大,但是能调度的Pod数量就比较有限了;

  7. 30 GB或以上的可用磁盘空间,这主要是留给Docker镜像和日志文件用的。

在本次部署中,我准备的机器配置如下:

  1. 2核CPU、 7.5 GB内存;

  2. 30 GB磁盘;

  3. Ubuntu 16.04;

  4. 内网互通;

  5. 外网访问权限不受限制。

备注:在开始部署前,我推荐你先花几分钟时间,回忆一下Kubernetes的架构。

然后,我再和你介绍一下今天实践的目标:

  1. 在所有节点上安装Docker和kubeadm;

  2. 部署Kubernetes Master;

  3. 部署容器网络插件;

  4. 部署Kubernetes Worker;

  5. 部署Dashboard可视化插件;

  6. 部署容器存储插件。

好了,现在,就来开始这次集群部署之旅吧!

安装kubeadm和Docker

我在上一篇文章《 Kubernetes一键部署利器:kubeadm》中,已经介绍过kubeadm的基础用法,它的一键安装非常方便,我们只需要添加kubeadm的源,然后直接使用apt-get安装即可,具体流程如下所示:

备注:为了方便讲解,我后续都会直接在root用户下进行操作。

$ curl -s https://packages.cloud.google.com/apt/doc/apt-key.gpg | apt-key add -
$ cat <<EOF > /etc/apt/sources.list.d/kubernetes.list
deb http://apt.kubernetes.io/ kubernetes-xenial main
EOF
$ apt-get update
$ apt-get install -y docker.io kubeadm

提示:如果 apt.kubernetes.io 因为网络问题访问不到,可以换成中科大的 Ubuntu 镜像源deb http://mirrors.ustc.edu.cn/kubernetes/apt kubernetes-xenial main。

在上述安装kubeadm的过程中,kubeadm和kubelet、kubectl、kubernetes-cni这几个二进制文件都会被自动安装好。

另外,这里我直接使用Ubuntu的docker.io的安装源,原因是Docker公司每次发布的最新的Docker CE(社区版)产品往往还没有经过Kubernetes项目的验证,可能会有兼容性方面的问题。

部署Kubernetes的Master节点

在上一篇文章中,我已经介绍过kubeadm可以一键部署Master节点。不过,在本篇文章中既然要部署一个“完整”的Kubernetes集群,那我们不妨稍微提高一下难度:通过配置文件来开启一些实验性功能。

所以,这里我编写了一个给kubeadm用的YAML文件(名叫:kubeadm.yaml):

apiVersion: kubeadm.k8s.io/v1alpha1
kind: MasterConfiguration
controllerManagerExtraArgs:
  horizontal-pod-autoscaler-use-rest-clients: "true"
  horizontal-pod-autoscaler-sync-period: "10s"
  node-monitor-grace-period: "10s"
apiServerExtraArgs:
  runtime-config: "api/all=true"
kubernetesVersion: "stable-1.11"

这个配置中,我给kube-controller-manager设置了:

horizontal-pod-autoscaler-use-rest-clients: "true"

这意味着,将来部署的kube-controller-manager能够使用自定义资源(Custom Metrics)进行自动水平扩展。这是我后面文章中会重点介绍的一个内容。

其中,“stable-1.11”就是kubeadm帮我们部署的Kubernetes版本号,即:Kubernetes release 1.11最新的稳定版,在我的环境下,它是v1.11.1。你也可以直接指定这个版本,比如:kubernetesVersion: “v1.11.1”。

然后,我们只需要执行一句指令:

$ kubeadm init --config kubeadm.yaml

就可以完成Kubernetes Master的部署了,这个过程只需要几分钟。部署完成后,kubeadm会生成一行指令:

kubeadm join 10.168.0.2:6443 --token 00bwbx.uvnaa2ewjflwu1ry --discovery-token-ca-cert-hash sha256:00eb62a2a6020f94132e3fe1ab721349bbcd3e9b94da9654cfe15f2985ebd711

这个kubeadm join命令,就是用来给这个Master节点添加更多工作节点(Worker)的命令。我们在后面部署Worker节点的时候马上会用到它,所以找一个地方把这条命令记录下来。

此外,kubeadm还会提示我们第一次使用Kubernetes集群所需要的配置命令:

mkdir -p $HOME/.kube
sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config
sudo chown $(id -u):$(id -g) $HOME/.kube/config

而需要这些配置命令的原因是:Kubernetes集群默认需要加密方式访问。所以,这几条命令,就是将刚刚部署生成的Kubernetes集群的安全配置文件,保存到当前用户的.kube目录下,kubectl默认会使用这个目录下的授权信息访问Kubernetes集群。

如果不这么做的话,我们每次都需要通过export KUBECONFIG环境变量告诉kubectl这个安全配置文件的位置。

现在,我们就可以使用kubectl get命令来查看当前唯一一个节点的状态了:

$ kubectl get nodes

NAME      STATUS     ROLES     AGE       VERSION
master    NotReady   master    1d        v1.11.1

可以看到,这个get指令输出的结果里,Master节点的状态是NotReady,这是为什么呢?

在调试Kubernetes集群时,最重要的手段就是用kubectl describe来查看这个节点(Node)对象的详细信息、状态和事件(Event),我们来试一下:

$ kubectl describe node master

...
Conditions:
...

Ready   False ... KubeletNotReady  runtime network not ready: NetworkReady=false reason:NetworkPluginNotReady message:docker: network plugin is not ready: cni config uninitialized

通过kubectl describe指令的输出,我们可以看到NodeNotReady的原因在于,我们尚未部署任何网络插件。

另外,我们还可以通过kubectl检查这个节点上各个系统Pod的状态,其中,kube-system是Kubernetes项目预留的系统Pod的工作空间(Namepsace,注意它并不是Linux Namespace,它只是Kubernetes划分不同工作空间的单位):

$ kubectl get pods -n kube-system

NAME               READY   STATUS   RESTARTS  AGE
coredns-78fcdf6894-j9s52     0/1    Pending  0     1h
coredns-78fcdf6894-jm4wf     0/1    Pending  0     1h
etcd-master           1/1    Running  0     2s
kube-apiserver-master      1/1    Running  0     1s
kube-controller-manager-master  0/1    Pending  0     1s
kube-proxy-xbd47         1/1    NodeLost  0     1h
kube-scheduler-master      1/1    Running  0     1s

可以看到,CoreDNS、kube-controller-manager等依赖于网络的Pod都处于Pending状态,即调度失败。这当然是符合预期的:因为这个Master节点的网络尚未就绪。

部署网络插件

在Kubernetes项目“一切皆容器”的设计理念指导下,部署网络插件非常简单,只需要执行一句kubectl apply指令,以Weave为例:

$ kubectl apply -f https://git.io/weave-kube-1.6

部署完成后,我们可以通过kubectl get重新检查Pod的状态:

$ kubectl get pods -n kube-system

NAME                             READY     STATUS    RESTARTS   AGE
coredns-78fcdf6894-j9s52         1/1       Running   0          1d
coredns-78fcdf6894-jm4wf         1/1       Running   0          1d
etcd-master                      1/1       Running   0          9s
kube-apiserver-master            1/1       Running   0          9s
kube-controller-manager-master   1/1       Running   0          9s
kube-proxy-xbd47                 1/1       Running   0          1d
kube-scheduler-master            1/1       Running   0          9s
weave-net-cmk27                  2/2       Running   0          19s

可以看到,所有的系统Pod都成功启动了,而刚刚部署的Weave网络插件则在kube-system下面新建了一个名叫weave-net-cmk27的Pod,一般来说,这些Pod就是容器网络插件在每个节点上的控制组件。

Kubernetes支持容器网络插件,使用的是一个名叫CNI的通用接口,它也是当前容器网络的事实标准,市面上的所有容器网络开源项目都可以通过CNI接入Kubernetes,比如Flannel、Calico、Canal、Romana等等,它们的部署方式也都是类似的“一键部署”。关于这些开源项目的实现细节和差异,我会在后续的网络部分详细介绍。

至此,Kubernetes的Master节点就部署完成了。如果你只需要一个单节点的Kubernetes,现在你就可以使用了。不过,在默认情况下,Kubernetes的Master节点是不能运行用户Pod的,所以还需要额外做一个小操作。在本篇的最后部分,我会介绍到它。

部署Kubernetes的Worker节点

Kubernetes的Worker节点跟Master节点几乎是相同的,它们运行着的都是一个kubelet组件。唯一的区别在于,在kubeadm init的过程中,kubelet启动后,Master节点上还会自动运行kube-apiserver、kube-scheduler、kube-controller-manger这三个系统Pod。

所以,相比之下,部署Worker节点反而是最简单的,只需要两步即可完成。

第一步,在所有Worker节点上执行“安装kubeadm和Docker”一节的所有步骤。

第二步,执行部署Master节点时生成的kubeadm join指令:

$ kubeadm join 10.168.0.2:6443 --token 00bwbx.uvnaa2ewjflwu1ry --discovery-token-ca-cert-hash sha256:00eb62a2a6020f94132e3fe1ab721349bbcd3e9b94da9654cfe15f2985ebd711

通过Taint/Toleration调整Master执行Pod的策略

我在前面提到过,默认情况下Master节点是不允许运行用户Pod的。而Kubernetes做到这一点,依靠的是Kubernetes的Taint/Toleration机制。

它的原理非常简单:一旦某个节点被加上了一个Taint,即被“打上了污点”,那么所有Pod就都不能在这个节点上运行,因为Kubernetes的Pod都有“洁癖”。

除非,有个别的Pod声明自己能“容忍”这个“污点”,即声明了Toleration,它才可以在这个节点上运行。

其中,为节点打上“污点”(Taint)的命令是:

$ kubectl taint nodes node1 foo=bar:NoSchedule

这时,该node1节点上就会增加一个键值对格式的Taint,即:foo=bar:NoSchedule。其中值里面的NoSchedule,意味着这个Taint只会在调度新Pod时产生作用,而不会影响已经在node1上运行的Pod,哪怕它们没有Toleration。

那么Pod又如何声明Toleration呢?

我们只要在Pod的.yaml文件中的spec部分,加入tolerations字段即可:

apiVersion: v1
kind: Pod
...
spec:
  tolerations:
  - key: "foo"
    operator: "Equal"
    value: "bar"
    effect: "NoSchedule"

这个Toleration的含义是,这个Pod能“容忍”所有键值对为foo=bar的Taint( operator: “Equal”,“等于”操作)。

现在回到我们已经搭建的集群上来。这时,如果你通过kubectl describe检查一下Master节点的Taint字段,就会有所发现了:

$ kubectl describe node master

Name:               master
Roles:              master
Taints:             node-role.kubernetes.io/master:NoSchedule

可以看到,Master节点默认被加上了node-role.kubernetes.io/master:NoSchedule这样一个“污点”,其中“键”是node-role.kubernetes.io/master,而没有提供“值”。

此时,你就需要像下面这样用“Exists”操作符(operator: “Exists”,“存在”即可)来说明,该Pod能够容忍所有以foo为键的Taint,才能让这个Pod运行在该Master节点上:

apiVersion: v1
kind: Pod
...
spec:
  tolerations:
  - key: "foo"
    operator: "Exists"
    effect: "NoSchedule"

当然,如果你就是想要一个单节点的Kubernetes,删除这个Taint才是正确的选择:

$ kubectl taint nodes --all node-role.kubernetes.io/master-

如上所示,我们在“node-role.kubernetes.io/master”这个键后面加上了一个短横线“-”,这个格式就意味着移除所有以“node-role.kubernetes.io/master”为键的Taint。

到了这一步,一个基本完整的Kubernetes集群就部署完毕了。是不是很简单呢?

有了kubeadm这样的原生管理工具,Kubernetes的部署已经被大大简化。更重要的是,像证书、授权、各个组件的配置等部署中最麻烦的操作,kubeadm都已经帮你完成了。

接下来,我们再在这个Kubernetes集群上安装一些其他的辅助插件,比如Dashboard和存储插件。

部署Dashboard可视化插件

在Kubernetes社区中,有一个很受欢迎的Dashboard项目,它可以给用户提供一个可视化的Web界面来查看当前集群的各种信息。毫不意外,它的部署也相当简单:

$ kubectl apply -f
$ $ kubectl apply -f https://raw.githubusercontent.com/kubernetes/dashboard/v2.0.0-rc6/aio/deploy/recommended.yaml

部署完成之后,我们就可以查看Dashboard对应的Pod的状态了:

$ kubectl get pods -n kube-system

kubernetes-dashboard-6948bdb78-f67xk   1/1       Running   0          1m

需要注意的是,由于Dashboard是一个Web Server,很多人经常会在自己的公有云上无意地暴露Dashboard的端口,从而造成安全隐患。所以,1.7版本之后的Dashboard项目部署完成后,默认只能通过Proxy的方式在本地访问。具体的操作,你可以查看Dashboard项目的官方文档

而如果你想从集群外访问这个Dashboard的话,就需要用到Ingress,我会在后面的文章中专门介绍这部分内容。

部署容器存储插件

接下来,让我们完成这个Kubernetes集群的最后一块拼图:容器持久化存储。

我在前面介绍容器原理时已经提到过,很多时候我们需要用数据卷(Volume)把外面宿主机上的目录或者文件挂载进容器的Mount Namespace中,从而达到容器和宿主机共享这些目录或者文件的目的。容器里的应用,也就可以在这些数据卷中新建和写入文件。

可是,如果你在某一台机器上启动的一个容器,显然无法看到其他机器上的容器在它们的数据卷里写入的文件。这是容器最典型的特征之一:无状态。

而容器的持久化存储,就是用来保存容器存储状态的重要手段:存储插件会在容器里挂载一个基于网络或者其他机制的远程数据卷,使得在容器里创建的文件,实际上是保存在远程存储服务器上,或者以分布式的方式保存在多个节点上,而与当前宿主机没有任何绑定关系。这样,无论你在其他哪个宿主机上启动新的容器,都可以请求挂载指定的持久化存储卷,从而访问到数据卷里保存的内容。这就是“持久化”的含义。

由于Kubernetes本身的松耦合设计,绝大多数存储项目,比如Ceph、GlusterFS、NFS等,都可以为Kubernetes提供持久化存储能力。在这次的部署实战中,我会选择部署一个很重要的Kubernetes存储插件项目:Rook。

Rook项目是一个基于Ceph的Kubernetes存储插件(它后期也在加入对更多存储实现的支持)。不过,不同于对Ceph的简单封装,Rook在自己的实现中加入了水平扩展、迁移、灾难备份、监控等大量的企业级功能,使得这个项目变成了一个完整的、生产级别可用的容器存储插件。

得益于容器化技术,用几条指令,Rook就可以把复杂的Ceph存储后端部署起来:

$ kubectl apply -f https://raw.githubusercontent.com/rook/rook/master/cluster/examples/kubernetes/ceph/common.yaml

$ kubectl apply -f https://raw.githubusercontent.com/rook/rook/master/cluster/examples/kubernetes/ceph/operator.yaml

$ kubectl apply -f https://raw.githubusercontent.com/rook/rook/master/cluster/examples/kubernetes/ceph/cluster.yaml

在部署完成后,你就可以看到Rook项目会将自己的Pod放置在由它自己管理的两个Namespace当中:

$ kubectl get pods -n rook-ceph-system
NAME                                  READY     STATUS    RESTARTS   AGE
rook-ceph-agent-7cv62                 1/1       Running   0          15s
rook-ceph-operator-78d498c68c-7fj72   1/1       Running   0          44s
rook-discover-2ctcv                   1/1       Running   0          15s

$ kubectl get pods -n rook-ceph
NAME                   READY     STATUS    RESTARTS   AGE
rook-ceph-mon0-kxnzh   1/1       Running   0          13s
rook-ceph-mon1-7dn2t   1/1       Running   0          2s

这样,一个基于Rook持久化存储集群就以容器的方式运行起来了,而接下来在Kubernetes项目上创建的所有Pod就能够通过Persistent Volume(PV)和Persistent Volume Claim(PVC)的方式,在容器里挂载由Ceph提供的数据卷了。

而Rook项目,则会负责这些数据卷的生命周期管理、灾难备份等运维工作。关于这些容器持久化存储的知识,我会在后续章节中专门讲解。

这时候,你可能会有个疑问:为什么我要选择Rook项目呢?

其实,是因为这个项目很有前途。

如果你去研究一下Rook项目的实现,就会发现它巧妙地依赖了Kubernetes提供的编排能力,合理的使用了很多诸如Operator、CRD等重要的扩展特性(这些特性我都会在后面的文章中逐一讲解到)。这使得Rook项目,成为了目前社区中基于Kubernetes API构建的最完善也最成熟的容器存储插件。我相信,这样的发展路线,很快就会得到整个社区的推崇。

备注:其实,在很多时候,大家说的所谓“云原生”,就是“Kubernetes原生”的意思。而像Rook、Istio这样的项目,正是贯彻这个思路的典范。在我们后面讲解了声明式API之后,相信你对这些项目的设计思想会有更深刻的体会。

总结

在本篇文章中,我们完全从0开始,在Bare-metal环境下使用kubeadm工具部署了一个完整的Kubernetes集群:这个集群有一个Master节点和多个Worker节点;使用Weave作为容器网络插件;使用Rook作为容器持久化存储插件;使用Dashboard插件提供了可视化的Web界面。

这个集群,也将会是我进行后续讲解所依赖的集群环境,并且在后面的讲解中,我还会给它安装更多的插件,添加更多的新能力。

另外,这个集群的部署过程并不像传说中那么繁琐,这主要得益于:

  1. kubeadm项目大大简化了部署Kubernetes的准备工作,尤其是配置文件、证书、二进制文件的准备和制作,以及集群版本管理等操作,都被kubeadm接管了。

  2. Kubernetes本身“一切皆容器”的设计思想,加上良好的可扩展机制,使得插件的部署非常简便。

上述思想,也是开发和使用Kubernetes的重要指导思想,即:基于Kubernetes开展工作时,你一定要优先考虑这两个问题:

  1. 我的工作是不是可以容器化?

  2. 我的工作是不是可以借助Kubernetes API和可扩展机制来完成?

而一旦这项工作能够基于Kubernetes实现容器化,就很有可能像上面的部署过程一样,大幅简化原本复杂的运维工作。对于时间宝贵的技术人员来说,这个变化的重要性是不言而喻的。

思考题

  1. 你是否使用其他工具部署过Kubernetes项目?经历如何?

  2. 你是否知道Kubernetes项目当前(v1.11)能够有效管理的集群规模是多少个节点?你在生产环境中希望部署或者正在部署的集群规模又是多少个节点呢?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

12-牛刀小试:我的第一个容器化应用

你好,我是张磊。今天我和你分享的主题是:牛刀小试之我的第一个容器化应用。

在上一篇文章《从0到1:搭建一个完整的Kubernetes集群》中,我和你一起部署了一套完整的Kubernetes集群。这个集群虽然离生产环境的要求还有一定差距(比如,没有一键高可用部署),但也可以当作是一个准生产级别的Kubernetes集群了。

而在这篇文章中,我们就来扮演一个应用开发者的角色,使用这个Kubernetes集群发布第一个容器化应用。

在开始实践之前,我先给你讲解一下Kubernetes里面与开发者关系最密切的几个概念。

作为一个应用开发者,你首先要做的,是制作容器的镜像。这一部分内容,我已经在容器基础部分《白话容器基础(三):深入理解容器镜像》重点讲解过了。

而有了容器镜像之后,你需要按照Kubernetes项目的规范和要求,将你的镜像组织为它能够“认识”的方式,然后提交上去。

那么,什么才是Kubernetes项目能“认识”的方式呢?

这就是使用Kubernetes的必备技能:编写配置文件。

备注:这些配置文件可以是YAML或者JSON格式的。为方便阅读与理解,在后面的讲解中,我会统一使用YAML文件来指代它们。

Kubernetes跟Docker等很多项目最大的不同,就在于它不推荐你使用命令行的方式直接运行容器(虽然Kubernetes项目也支持这种方式,比如:kubectl run),而是希望你用YAML文件的方式,即:把容器的定义、参数、配置,统统记录在一个YAML文件中,然后用这样一句指令把它运行起来:

$ kubectl create -f 我的配置文件

这么做最直接的好处是,你会有一个文件能记录下Kubernetes到底“run”了什么。比如下面这个例子:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
spec:
  selector:
    matchLabels:
      app: nginx
  replicas: 2
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.7.9
        ports:
        - containerPort: 80

像这样的一个YAML文件,对应到Kubernetes中,就是一个API Object(API对象)。当你为这个对象的各个字段填好值并提交给Kubernetes之后,Kubernetes就会负责创建出这些对象所定义的容器或者其他类型的API资源。

可以看到,这个YAML文件中的Kind字段,指定了这个API对象的类型(Type),是一个Deployment。

所谓Deployment,是一个定义多副本应用(即多个副本Pod)的对象,我在前面的文章中(也是第9篇文章《从容器到容器云:谈谈Kubernetes的本质》)曾经简单提到过它的用法。此外,Deployment还负责在Pod定义发生变化时,对每个副本进行滚动更新(Rolling Update)。

在上面这个YAML文件中,我给它定义的Pod副本个数(spec.replicas)是:2。

而这些Pod具体的又长什么样子呢?

为此,我定义了一个Pod模版(spec.template),这个模版描述了我想要创建的Pod的细节。在上面的例子里,这个Pod里只有一个容器,这个容器的镜像(spec.containers.image)是nginx:1.7.9,这个容器监听端口(containerPort)是80。

关于Pod的设计和用法我已经在第9篇文章《从容器到容器云:谈谈Kubernetes的本质》中简单的介绍过。而在这里,你需要记住这样一句话:

Pod就是Kubernetes世界里的“应用”;而一个应用,可以由多个容器组成。

需要注意的是,像这样使用一种API对象(Deployment)管理另一种API对象(Pod)的方法,在Kubernetes中,叫作“控制器”模式(controller pattern)。在我们的例子中,Deployment扮演的正是Pod的控制器的角色。关于Pod和控制器模式的更多细节,我会在后续编排部分做进一步讲解。

你可能还注意到,这样的每一个API对象都有一个叫作Metadata的字段,这个字段就是API对象的“标识”,即元数据,它也是我们从Kubernetes里找到这个对象的主要依据。这其中最主要使用到的字段是Labels。

顾名思义,Labels就是一组key-value格式的标签。而像Deployment这样的控制器对象,就可以通过这个Labels字段从Kubernetes中过滤出它所关心的被控制对象。

比如,在上面这个YAML文件中,Deployment会把所有正在运行的、携带“app: nginx”标签的Pod识别为被管理的对象,并确保这些Pod的总数严格等于两个。

而这个过滤规则的定义,是在Deployment的“spec.selector.matchLabels”字段。我们一般称之为:Label Selector。

另外,在Metadata中,还有一个与Labels格式、层级完全相同的字段叫Annotations,它专门用来携带key-value格式的内部信息。所谓内部信息,指的是对这些信息感兴趣的,是Kubernetes组件本身,而不是用户。所以大多数Annotations,都是在Kubernetes运行过程中,被自动加在这个API对象上。

一个Kubernetes的API对象的定义,大多可以分为Metadata和Spec两个部分。前者存放的是这个对象的元数据,对所有API对象来说,这一部分的字段和格式基本上是一样的;而后者存放的,则是属于这个对象独有的定义,用来描述它所要表达的功能。

在了解了上述Kubernetes配置文件的基本知识之后,我们现在就可以把这个YAML文件“运行”起来。正如前所述,你可以使用kubectl create指令完成这个操作:

$ kubectl create -f nginx-deployment.yaml

然后,通过kubectl get命令检查这个YAML运行起来的状态是不是与我们预期的一致:

$ kubectl get pods -l app=nginx
NAME                                READY     STATUS    RESTARTS   AGE
nginx-deployment-67594d6bf6-9gdvr   1/1       Running   0          10m
nginx-deployment-67594d6bf6-v6j7w   1/1       Running   0          10m

kubectl get指令的作用,就是从Kubernetes里面获取(GET)指定的API对象。可以看到,在这里我还加上了一个-l参数,即获取所有匹配app: nginx标签的Pod。需要注意的是,在命令行中,所有key-value格式的参数,都使用“=”而非“:”表示。

从这条指令返回的结果中,我们可以看到现在有两个Pod处于Running状态,也就意味着我们这个Deployment所管理的Pod都处于预期的状态。

此外, 你还可以使用kubectl describe命令,查看一个API对象的细节,比如:

$ kubectl describe pod nginx-deployment-67594d6bf6-9gdvr
Name:               nginx-deployment-67594d6bf6-9gdvr
Namespace:          default
Priority:           0
PriorityClassName:  <none>
Node:               node-1/10.168.0.3
Start Time:         Thu, 16 Aug 2018 08:48:42 +0000
Labels:             app=nginx
                    pod-template-hash=2315082692
Annotations:        <none>
Status:             Running
IP:                 10.32.0.23
Controlled By:      ReplicaSet/nginx-deployment-67594d6bf6
...
Events:

  Type     Reason                  Age                From               Message

  ----     ------                  ----               ----               -------
  
  Normal   Scheduled               1m                 default-scheduler  Successfully assigned default/nginx-deployment-67594d6bf6-9gdvr to node-1
  Normal   Pulling                 25s                kubelet, node-1    pulling image "nginx:1.7.9"
  Normal   Pulled                  17s                kubelet, node-1    Successfully pulled image "nginx:1.7.9"
  Normal   Created                 17s                kubelet, node-1    Created container
  Normal   Started                 17s                kubelet, node-1    Started container

在kubectl describe命令返回的结果中,你可以清楚地看到这个Pod的详细信息,比如它的IP地址等等。其中,有一个部分值得你特别关注,它就是Events(事件)。

在Kubernetes执行的过程中,对API对象的所有重要操作,都会被记录在这个对象的Events里,并且显示在kubectl describe指令返回的结果中。

比如,对于这个Pod,我们可以看到它被创建之后,被调度器调度(Successfully assigned)到了node-1,拉取了指定的镜像(pulling image),然后启动了Pod里定义的容器(Started container)。

所以,这个部分正是我们将来进行Debug的重要依据。如果有异常发生,你一定要第一时间查看这些Events,往往可以看到非常详细的错误信息。

接下来,如果我们要对这个Nginx服务进行升级,把它的镜像版本从1.7.9升级为1.8,要怎么做呢?

很简单,我们只要修改这个YAML文件即可。

...    
    spec:
      containers:
      - name: nginx
        image: nginx:1.8 #这里被从1.7.9修改为1.8
        ports:
      - containerPort: 80

可是,这个修改目前只发生在本地,如何让这个更新在Kubernetes里也生效呢?

我们可以使用kubectl replace指令来完成这个更新:

 $ kubectl replace -f nginx-deployment.yaml

不过,在本专栏里,我推荐你使用kubectl apply命令,来统一进行Kubernetes对象的创建和更新操作,具体做法如下所示:

$ kubectl apply -f nginx-deployment.yaml

# 修改nginx-deployment.yaml的内容

$ kubectl apply -f nginx-deployment.yaml

这样的操作方法,是Kubernetes“声明式API”所推荐的使用方法。也就是说,作为用户,你不必关心当前的操作是创建,还是更新,你执行的命令始终是kubectl apply,而Kubernetes则会根据YAML文件的内容变化,自动进行具体的处理。

而这个流程的好处是,它有助于帮助开发和运维人员,围绕着可以版本化管理的YAML文件,而不是“行踪不定”的命令行进行协作,从而大大降低开发人员和运维人员之间的沟通成本。

举个例子,一位开发人员开发好一个应用,制作好了容器镜像。那么他就可以在应用的发布目录里附带上一个Deployment的YAML文件。

而运维人员,拿到这个应用的发布目录后,就可以直接用这个YAML文件执行kubectl apply操作把它运行起来。

这时候,如果开发人员修改了应用,生成了新的发布内容,那么这个YAML文件,也就需要被修改,并且成为这次变更的一部分。

而接下来,运维人员可以使用git diff命令查看到这个YAML文件本身的变化,然后继续用kubectl apply命令更新这个应用。

所以说,如果通过容器镜像,我们能够保证应用本身在开发与部署环境里的一致性的话,那么现在,Kubernetes项目通过这些YAML文件,就保证了应用的“部署参数”在开发与部署环境中的一致性。

而当应用本身发生变化时,开发人员和运维人员可以依靠容器镜像来进行同步;当应用部署参数发生变化时,这些YAML文件就是他们相互沟通和信任的媒介。

以上,就是Kubernetes发布应用的最基本操作了。

接下来,我们再在这个Deployment中尝试声明一个Volume。

在Kubernetes中,Volume是属于Pod对象的一部分。所以,我们就需要修改这个YAML文件里的template.spec字段,如下所示:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
spec:
  selector:
    matchLabels:
      app: nginx
  replicas: 2
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.8
        ports:
        - containerPort: 80
        volumeMounts:
        - mountPath: "/usr/share/nginx/html"
          name: nginx-vol
      volumes:
      - name: nginx-vol
        emptyDir: {}

可以看到,我们在Deployment的Pod模板部分添加了一个volumes字段,定义了这个Pod声明的所有Volume。它的名字叫作nginx-vol,类型是emptyDir。

那什么是emptyDir类型呢?

它其实就等同于我们之前讲过的Docker的隐式Volume参数,即:不显式声明宿主机目录的Volume。所以,Kubernetes也会在宿主机上创建一个临时目录,这个目录将来就会被绑定挂载到容器所声明的Volume目录上。

备注:不难看到,Kubernetes的emptyDir类型,只是把Kubernetes创建的临时目录作为Volume的宿主机目录,交给了Docker。这么做的原因,是Kubernetes不想依赖Docker自己创建的那个_data目录。

而Pod中的容器,使用的是volumeMounts字段来声明自己要挂载哪个Volume,并通过mountPath字段来定义容器内的Volume目录,比如:/usr/share/nginx/html。

当然,Kubernetes也提供了显式的Volume定义,它叫作hostPath。比如下面的这个YAML文件:

 ...  
    volumes:
      - name: nginx-vol
        hostPath:
          path:  " /var/data"

这样,容器Volume挂载的宿主机目录,就变成了/var/data。

在上述修改完成后,我们还是使用kubectl apply指令,更新这个Deployment:

$ kubectl apply -f nginx-deployment.yaml

接下来,你可以通过kubectl get指令,查看两个Pod被逐一更新的过程:

$ kubectl get pods
NAME                                READY     STATUS              RESTARTS   AGE
nginx-deployment-5c678cfb6d-v5dlh   0/1       ContainerCreating   0          4s
nginx-deployment-67594d6bf6-9gdvr   1/1       Running             0          10m
nginx-deployment-67594d6bf6-v6j7w   1/1       Running             0          10m
$ kubectl get pods
NAME                                READY     STATUS    RESTARTS   AGE
nginx-deployment-5c678cfb6d-lg9lw   1/1       Running   0          8s
nginx-deployment-5c678cfb6d-v5dlh   1/1       Running   0          19s

从返回结果中,我们可以看到,新旧两个Pod,被交替创建、删除,最后剩下的就是新版本的Pod。这个滚动更新的过程,我也会在后续进行详细的讲解。

然后,你可以使用kubectl describe查看一下最新的Pod,就会发现Volume的信息已经出现在了Container描述部分:

...
Containers:
  nginx:
    Container ID:   docker://07b4f89248791c2aa47787e3da3cc94b48576cd173018356a6ec8db2b6041343
    Image:          nginx:1.8
    ...
    Environment:    <none>
    Mounts:
      /usr/share/nginx/html from nginx-vol (rw)
...
Volumes:
  nginx-vol:
    Type:    EmptyDir (a temporary directory that shares a pod's lifetime)

备注:作为一个完整的容器化平台项目,Kubernetes为我们提供的Volume类型远远不止这些,在容器存储章节里,我将会为你详细介绍这部分内容。

最后,你还可以使用kubectl exec指令,进入到这个Pod当中(即容器的Namespace中)查看这个Volume目录:

$ kubectl exec -it nginx-deployment-5c678cfb6d-lg9lw -- /bin/bash
# ls /usr/share/nginx/html

此外,你想要从Kubernetes集群中删除这个Nginx Deployment的话,直接执行:

$ kubectl delete -f nginx-deployment.yaml

就可以了。

总结

在今天的分享中,我通过一个小案例,和你近距离体验了Kubernetes的使用方法。

可以看到,Kubernetes推荐的使用方式,是用一个YAML文件来描述你所要部署的API对象。然后,统一使用kubectl apply命令完成对这个对象的创建和更新操作。

而Kubernetes里“最小”的API对象是Pod。Pod可以等价为一个应用,所以,Pod可以由多个紧密协作的容器组成。

在Kubernetes中,我们经常会看到它通过一种API对象来管理另一种API对象,比如Deployment和Pod之间的关系;而由于Pod是“最小”的对象,所以它往往都是被其他对象控制的。这种组合方式,正是Kubernetes进行容器编排的重要模式。

而像这样的Kubernetes API对象,往往由Metadata和Spec两部分组成,其中Metadata里的Labels字段是Kubernetes过滤对象的主要手段。

在这些字段里面,容器想要使用的数据卷,也就是Volume,正是Pod的Spec字段的一部分。而Pod里的每个容器,则需要显式的声明自己要挂载哪个Volume。

上面这些基于YAML文件的容器管理方式,跟Docker、Mesos的使用习惯都是不一样的,而从docker run这样的命令行操作,向kubectl apply YAML文件这样的声明式API的转变,是每一个容器技术学习者,必须要跨过的第一道门槛。

所以,如果你想要快速熟悉Kubernetes,请按照下面的流程进行练习:

  • 首先,在本地通过Docker测试代码,制作镜像;
  • 然后,选择合适的Kubernetes API对象,编写对应YAML文件(比如,Pod,Deployment);
  • 最后,在Kubernetes上部署这个YAML文件。

更重要的是,在部署到Kubernetes之后,接下来的所有操作,要么通过kubectl来执行,要么通过修改YAML文件来实现,就尽量不要再碰Docker的命令行了

思考题

在实际使用Kubernetes的过程中,相比于编写一个单独的Pod的YAML文件,我一定会推荐你使用一个replicas=1的Deployment。请问,这两者有什么区别呢?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

13-为什么我们需要Pod?

你好,我是张磊。今天我和你分享的主题是:为什么我们需要Pod。

在前面的文章中,我详细介绍了在Kubernetes里部署一个应用的过程。在这些讲解中,我提到了这样一个知识点:Pod,是Kubernetes项目中最小的API对象。如果换一个更专业的说法,我们可以这样描述:Pod,是Kubernetes项目的原子调度单位。

不过,我相信你在学习和使用Kubernetes项目的过程中,已经不止一次地想要问这样一个问题:为什么我们会需要Pod?

是啊,我们在前面已经花了很多精力去解读Linux容器的原理、分析了Docker容器的本质,终于,“Namespace做隔离,Cgroups做限制,rootfs做文件系统”这样的“三句箴言”可以朗朗上口了,为什么Kubernetes项目又突然搞出一个Pod来呢?

要回答这个问题,我们还是要一起回忆一下我曾经反复强调的一个问题:容器的本质到底是什么?

你现在应该可以不假思索地回答出来:容器的本质是进程。

没错。容器,就是未来云计算系统中的进程;容器镜像就是这个系统里的“.exe”安装包。那么Kubernetes呢?

你应该也能立刻回答上来:Kubernetes就是操作系统!

非常正确。

现在,就让我们登录到一台Linux机器里,执行一条如下所示的命令:

$ pstree -g

这条命令的作用,是展示当前系统中正在运行的进程的树状结构。它的返回结果如下所示:

systemd(1)-+-accounts-daemon(1984)-+-{gdbus}(1984)
           | `-{gmain}(1984)
           |-acpid(2044)
          ...      
           |-lxcfs(1936)-+-{lxcfs}(1936)
           | `-{lxcfs}(1936)
           |-mdadm(2135)
           |-ntpd(2358)
           |-polkitd(2128)-+-{gdbus}(2128)
           | `-{gmain}(2128)
           |-rsyslogd(1632)-+-{in:imklog}(1632)
           |  |-{in:imuxsock) S 1(1632)
           | `-{rs:main Q:Reg}(1632)
           |-snapd(1942)-+-{snapd}(1942)
           |  |-{snapd}(1942)
           |  |-{snapd}(1942)
           |  |-{snapd}(1942)
           |  |-{snapd}(1942)

不难发现,在一个真正的操作系统里,进程并不是“孤苦伶仃”地独自运行的,而是以进程组的方式,“有原则地”组织在一起。比如,这里有一个叫作rsyslogd的程序,它负责的是Linux操作系统里的日志处理。可以看到,rsyslogd的主程序main,和它要用到的内核日志模块imklog等,同属于1632进程组。这些进程相互协作,共同完成rsyslogd程序的职责。

注意:我在本篇中提到的“进程”,比如,rsyslogd对应的imklog,imuxsock和main,严格意义上来说,其实是Linux 操作系统语境下的“线程”。这些线程,或者说,轻量级进程之间,可以共享文件、信号、数据内存、甚至部分代码,从而紧密协作共同完成一个程序的职责。所以同理,我提到的“进程组”,对应的也是 Linux 操作系统语境下的“线程组”。这种命名关系与实际情况的不一致,是Linux 发展历史中的一个遗留问题。对这个话题感兴趣的同学,可以阅读这篇技术文章来了解一下。

而Kubernetes项目所做的,其实就是将“进程组”的概念映射到了容器技术中,并使其成为了这个云计算“操作系统”里的“一等公民”。

Kubernetes项目之所以要这么做的原因,我在前面介绍Kubernetes和Borg的关系时曾经提到过:在Borg项目的开发和实践过程中,Google公司的工程师们发现,他们部署的应用,往往都存在着类似于“进程和进程组”的关系。更具体地说,就是这些应用之间有着密切的协作关系,使得它们必须部署在同一台机器上。

而如果事先没有“组”的概念,像这样的运维关系就会非常难以处理。

我还是以前面的rsyslogd为例子。已知rsyslogd由三个进程组成:一个imklog模块,一个imuxsock模块,一个rsyslogd自己的main函数主进程。这三个进程一定要运行在同一台机器上,否则,它们之间基于Socket的通信和文件交换,都会出现问题。

现在,我要把rsyslogd这个应用给容器化,由于受限于容器的“单进程模型”,这三个模块必须被分别制作成三个不同的容器。而在这三个容器运行的时候,它们设置的内存配额都是1 GB。

再次强调一下:容器的“单进程模型”,并不是指容器里只能运行“一个”进程,而是指容器没有管理多个进程的能力。这是因为容器里PID=1的进程就是应用本身,其他的进程都是这个PID=1进程的子进程。可是,用户编写的应用,并不能够像正常操作系统里的init进程或者systemd那样拥有进程管理的功能。比如,你的应用是一个Java Web程序(PID=1),然后你执行docker exec在后台启动了一个Nginx进程(PID=3)。可是,当这个Nginx进程异常退出的时候,你该怎么知道呢?这个进程退出后的垃圾收集工作,又应该由谁去做呢?

假设我们的Kubernetes集群上有两个节点:node-1上有3 GB可用内存,node-2有2.5 GB可用内存。

这时,假设我要用Docker Swarm来运行这个rsyslogd程序。为了能够让这三个容器都运行在同一台机器上,我就必须在另外两个容器上设置一个affinity=main(与main容器有亲密性)的约束,即:它们俩必须和main容器运行在同一台机器上。

然后,我顺序执行:“docker run main”“docker run imklog”和“docker run imuxsock”,创建这三个容器。

这样,这三个容器都会进入Swarm的待调度队列。然后,main容器和imklog容器都先后出队并被调度到了node-2上(这个情况是完全有可能的)。

可是,当imuxsock容器出队开始被调度时,Swarm就有点懵了:node-2上的可用资源只有0.5 GB了,并不足以运行imuxsock容器;可是,根据affinity=main的约束,imuxsock容器又只能运行在node-2上。

这就是一个典型的成组调度(gang scheduling)没有被妥善处理的例子。

在工业界和学术界,关于这个问题的讨论可谓旷日持久,也产生了很多可供选择的解决方案。

比如,Mesos中就有一个资源囤积(resource hoarding)的机制,会在所有设置了Affinity约束的任务都达到时,才开始对它们统一进行调度。而在Google Omega论文中,则提出了使用乐观调度处理冲突的方法,即:先不管这些冲突,而是通过精心设计的回滚机制在出现了冲突之后解决问题。

可是这些方法都谈不上完美。资源囤积带来了不可避免的调度效率损失和死锁的可能性;而乐观调度的复杂程度,则不是常规技术团队所能驾驭的。

但是,到了Kubernetes项目里,这样的问题就迎刃而解了:Pod是Kubernetes里的原子调度单位。这就意味着,Kubernetes项目的调度器,是统一按照Pod而非容器的资源需求进行计算的。

所以,像imklog、imuxsock和main函数主进程这样的三个容器,正是一个典型的由三个容器组成的Pod。Kubernetes项目在调度时,自然就会去选择可用内存等于3 GB的node-1节点进行绑定,而根本不会考虑node-2。

像这样容器间的紧密协作,我们可以称为“超亲密关系”。这些具有“超亲密关系”容器的典型特征包括但不限于:互相之间会发生直接的文件交换、使用localhost或者Socket文件进行本地通信、会发生非常频繁的远程调用、需要共享某些Linux Namespace(比如,一个容器要加入另一个容器的Network Namespace)等等。

这也就意味着,并不是所有有“关系”的容器都属于同一个Pod。比如,PHP应用容器和MySQL虽然会发生访问关系,但并没有必要、也不应该部署在同一台机器上,它们更适合做成两个Pod。

不过,相信此时你可能会有第二个疑问:

对于初学者来说,一般都是先学会了用Docker这种单容器的工具,才会开始接触Pod。

而如果Pod的设计只是出于调度上的考虑,那么Kubernetes项目似乎完全没有必要非得把Pod作为“一等公民”吧?这不是故意增加用户的学习门槛吗?

没错,如果只是处理“超亲密关系”这样的调度问题,有Borg和Omega论文珠玉在前,Kubernetes项目肯定可以在调度器层面给它解决掉。

不过,Pod在Kubernetes项目里还有更重要的意义,那就是:容器设计模式

为了理解这一层含义,我就必须先给你介绍一下Pod的实现原理。

首先,关于Pod最重要的一个事实是:它只是一个逻辑概念。

也就是说,Kubernetes真正处理的,还是宿主机操作系统上Linux容器的Namespace和Cgroups,而并不存在一个所谓的Pod的边界或者隔离环境。

那么,Pod又是怎么被“创建”出来的呢?

答案是:Pod,其实是一组共享了某些资源的容器。

具体的说:Pod里的所有容器,共享的是同一个Network Namespace,并且可以声明共享同一个Volume。

那这么来看的话,一个有A、B两个容器的Pod,不就是等同于一个容器(容器A)共享另外一个容器(容器B)的网络和Volume的玩儿法么?

这好像通过docker run --net --volumes-from这样的命令就能实现嘛,比如:

$ docker run --net=B --volumes-from=B --name=A image-A ...

但是,你有没有考虑过,如果真这样做的话,容器B就必须比容器A先启动,这样一个Pod里的多个容器就不是对等关系,而是拓扑关系了。

所以,在Kubernetes项目里,Pod的实现需要使用一个中间容器,这个容器叫作Infra容器。在这个Pod中,Infra容器永远都是第一个被创建的容器,而其他用户定义的容器,则通过Join Network Namespace的方式,与Infra容器关联在一起。这样的组织关系,可以用下面这样一个示意图来表达:


如上图所示,这个Pod里有两个用户容器A和B,还有一个Infra容器。很容易理解,在Kubernetes项目里,Infra容器一定要占用极少的资源,所以它使用的是一个非常特殊的镜像,叫作:k8s.gcr.io/pause。这个镜像是一个用汇编语言编写的、永远处于“暂停”状态的容器,解压后的大小也只有100~200 KB左右。

而在Infra容器“Hold住”Network Namespace后,用户容器就可以加入到Infra容器的Network Namespace当中了。所以,如果你查看这些容器在宿主机上的Namespace文件(这个Namespace文件的路径,我已经在前面的内容中介绍过),它们指向的值一定是完全一样的。

这也就意味着,对于Pod里的容器A和容器B来说:

  • 它们可以直接使用localhost进行通信;
  • 它们看到的网络设备跟Infra容器看到的完全一样;
  • 一个Pod只有一个IP地址,也就是这个Pod的Network Namespace对应的IP地址;
  • 当然,其他的所有网络资源,都是一个Pod一份,并且被该Pod中的所有容器共享;
  • Pod的生命周期只跟Infra容器一致,而与容器A和B无关。

而对于同一个Pod里面的所有用户容器来说,它们的进出流量,也可以认为都是通过Infra容器完成的。这一点很重要,因为将来如果你要为Kubernetes开发一个网络插件时,应该重点考虑的是如何配置这个Pod的Network Namespace,而不是每一个用户容器如何使用你的网络配置,这是没有意义的。

这就意味着,如果你的网络插件需要在容器里安装某些包或者配置才能完成的话,是不可取的:Infra容器镜像的rootfs里几乎什么都没有,没有你随意发挥的空间。当然,这同时也意味着你的网络插件完全不必关心用户容器的启动与否,而只需要关注如何配置Pod,也就是Infra容器的Network Namespace即可。

有了这个设计之后,共享Volume就简单多了:Kubernetes项目只要把所有Volume的定义都设计在Pod层级即可。

这样,一个Volume对应的宿主机目录对于Pod来说就只有一个,Pod里的容器只要声明挂载这个Volume,就一定可以共享这个Volume对应的宿主机目录。比如下面这个例子:

apiVersion: v1
kind: Pod
metadata:
  name: two-containers
spec:
  restartPolicy: Never
  volumes:
  - name: shared-data
    hostPath:      
      path: /data
  containers:
  - name: nginx-container
    image: nginx
    volumeMounts:
    - name: shared-data
      mountPath: /usr/share/nginx/html
  - name: debian-container
    image: debian
    volumeMounts:
    - name: shared-data
      mountPath: /pod-data
    command: ["/bin/sh"]
    args: ["-c", "echo Hello from the debian container > /pod-data/index.html"]

在这个例子中,debian-container和nginx-container都声明挂载了shared-data这个Volume。而shared-data是hostPath类型。所以,它对应在宿主机上的目录就是:/data。而这个目录,其实就被同时绑定挂载进了上述两个容器当中。

这就是为什么,nginx-container可以从它的/usr/share/nginx/html目录中,读取到debian-container生成的index.html文件的原因。

明白了Pod的实现原理后,我们再来讨论“容器设计模式”,就容易多了。

Pod这种“超亲密关系”容器的设计思想,实际上就是希望,当用户想在一个容器里跑多个功能并不相关的应用时,应该优先考虑它们是不是更应该被描述成一个Pod里的多个容器。

为了能够掌握这种思考方式,你就应该尽量尝试使用它来描述一些用单个容器难以解决的问题。

第一个最典型的例子是:WAR包与Web服务器。

我们现在有一个Java Web应用的WAR包,它需要被放在Tomcat的webapps目录下运行起来。

假如,你现在只能用Docker来做这件事情,那该如何处理这个组合关系呢?

  • 一种方法是,把WAR包直接放在Tomcat镜像的webapps目录下,做成一个新的镜像运行起来。可是,这时候,如果你要更新WAR包的内容,或者要升级Tomcat镜像,就要重新制作一个新的发布镜像,非常麻烦。
  • 另一种方法是,你压根儿不管WAR包,永远只发布一个Tomcat容器。不过,这个容器的webapps目录,就必须声明一个hostPath类型的Volume,从而把宿主机上的WAR包挂载进Tomcat容器当中运行起来。不过,这样你就必须要解决一个问题,即:如何让每一台宿主机,都预先准备好这个存储有WAR包的目录呢?这样来看,你只能独立维护一套分布式存储系统了。

实际上,有了Pod之后,这样的问题就很容易解决了。我们可以把WAR包和Tomcat分别做成镜像,然后把它们作为一个Pod里的两个容器“组合”在一起。这个Pod的配置文件如下所示:

apiVersion: v1
kind: Pod
metadata:
  name: javaweb-2
spec:
  initContainers:
  - image: geektime/sample:v2
    name: war
    command: ["cp", "/sample.war", "/app"]
    volumeMounts:
    - mountPath: /app
      name: app-volume
  containers:
  - image: geektime/tomcat:7.0
    name: tomcat
    command: ["sh","-c","/root/apache-tomcat-7.0.42-v2/bin/start.sh"]
    volumeMounts:
    - mountPath: /root/apache-tomcat-7.0.42-v2/webapps
      name: app-volume
    ports:
    - containerPort: 8080
      hostPort: 8001
  volumes:
  - name: app-volume
    emptyDir: {}

在这个Pod中,我们定义了两个容器,第一个容器使用的镜像是geektime/sample:v2,这个镜像里只有一个WAR包(sample.war)放在根目录下。而第二个容器则使用的是一个标准的Tomcat镜像。

不过,你可能已经注意到,WAR包容器的类型不再是一个普通容器,而是一个Init Container类型的容器。

在Pod中,所有Init Container定义的容器,都会比spec.containers定义的用户容器先启动。并且,Init Container容器会按顺序逐一启动,而直到它们都启动并且退出了,用户容器才会启动。

所以,这个Init Container类型的WAR包容器启动后,我执行了一句"cp /sample.war /app",把应用的WAR包拷贝到/app目录下,然后退出。

而后这个/app目录,就挂载了一个名叫app-volume的Volume。

接下来就很关键了。Tomcat容器,同样声明了挂载app-volume到自己的webapps目录下。

所以,等Tomcat容器启动时,它的webapps目录下就一定会存在sample.war文件:这个文件正是WAR包容器启动时拷贝到这个Volume里面的,而这个Volume是被这两个容器共享的。

像这样,我们就用一种“组合”方式,解决了WAR包与Tomcat容器之间耦合关系的问题。

实际上,这个所谓的“组合”操作,正是容器设计模式里最常用的一种模式,它的名字叫:sidecar。

顾名思义,sidecar指的就是我们可以在一个Pod中,启动一个辅助容器,来完成一些独立于主进程(主容器)之外的工作。

比如,在我们的这个应用Pod中,Tomcat容器是我们要使用的主容器,而WAR包容器的存在,只是为了给它提供一个WAR包而已。所以,我们用Init Container的方式优先运行WAR包容器,扮演了一个sidecar的角色。

第二个例子,则是容器的日志收集。

比如,我现在有一个应用,需要不断地把日志文件输出到容器的/var/log目录中。

这时,我就可以把一个Pod里的Volume挂载到应用容器的/var/log目录上。

然后,我在这个Pod里同时运行一个sidecar容器,它也声明挂载同一个Volume到自己的/var/log目录上。

这样,接下来sidecar容器就只需要做一件事儿,那就是不断地从自己的/var/log目录里读取日志文件,转发到MongoDB或者Elasticsearch中存储起来。这样,一个最基本的日志收集工作就完成了。

跟第一个例子一样,这个例子中的sidecar的主要工作也是使用共享的Volume来完成对文件的操作。

但不要忘记,Pod的另一个重要特性是,它的所有容器都共享同一个Network Namespace。这就使得很多与Pod网络相关的配置和管理,也都可以交给sidecar完成,而完全无须干涉用户容器。这里最典型的例子莫过于Istio这个微服务治理项目了。

Istio项目使用sidecar容器完成微服务治理的原理,我在后面很快会讲解到。

备注:Kubernetes社区曾经把“容器设计模式”这个理论,整理成了一篇小论文,你可以点击链接浏览。

总结

在本篇文章中我重点分享了Kubernetes项目中Pod的实现原理。

Pod是Kubernetes项目与其他单容器项目相比最大的不同,也是一位容器技术初学者需要面对的第一个与常规认知不一致的知识点。

事实上,直到现在,仍有很多人把容器跟虚拟机相提并论,他们把容器当做性能更好的虚拟机,喜欢讨论如何把应用从虚拟机无缝地迁移到容器中。

但实际上,无论是从具体的实现原理,还是从使用方法、特性、功能等方面,容器与虚拟机几乎没有任何相似的地方;也不存在一种普遍的方法,能够把虚拟机里的应用无缝迁移到容器中。因为,容器的性能优势,必然伴随着相应缺陷,即:它不能像虚拟机那样,完全模拟本地物理机环境中的部署方法。

所以,这个“上云”工作的完成,最终还是要靠深入理解容器的本质,即:进程。

实际上,一个运行在虚拟机里的应用,哪怕再简单,也是被管理在systemd或者supervisord之下的一组进程,而不是一个进程。这跟本地物理机上应用的运行方式其实是一样的。这也是为什么,从物理机到虚拟机之间的应用迁移,往往并不困难。

可是对于容器来说,一个容器永远只能管理一个进程。更确切地说,一个容器,就是一个进程。这是容器技术的“天性”,不可能被修改。所以,将一个原本运行在虚拟机里的应用,“无缝迁移”到容器中的想法,实际上跟容器的本质是相悖的。

这也是当初Swarm项目无法成长起来的重要原因之一:一旦到了真正的生产环境上,Swarm这种单容器的工作方式,就难以描述真实世界里复杂的应用架构了。

所以,你现在可以这么理解Pod的本质:

Pod,实际上是在扮演传统基础设施里“虚拟机”的角色;而容器,则是这个虚拟机里运行的用户程序。

所以下一次,当你需要把一个运行在虚拟机里的应用迁移到Docker容器中时,一定要仔细分析到底有哪些进程(组件)运行在这个虚拟机里。

然后,你就可以把整个虚拟机想象成为一个Pod,把这些进程分别做成容器镜像,把有顺序关系的容器,定义为Init Container。这才是更加合理的、松耦合的容器编排诀窍,也是从传统应用架构,到“微服务架构”最自然的过渡方式。

注意:Pod这个概念,提供的是一种编排思想,而不是具体的技术方案。所以,如果愿意的话,你完全可以使用虚拟机来作为Pod的实现,然后把用户容器都运行在这个虚拟机里。比如,Mirantis公司的virtlet项目就在干这个事情。甚至,你可以去实现一个带有Init进程的容器项目,来模拟传统应用的运行方式。这些工作,在Kubernetes中都是非常轻松的,也是我们后面讲解CRI时会提到的内容。

相反的,如果强行把整个应用塞到一个容器里,甚至不惜使用Docker In Docker这种在生产环境中后患无穷的解决方案,恐怕最后往往会得不偿失。

思考题

除了Network Namespace外,Pod里的容器还可以共享哪些Namespace呢?你能说出共享这些Namesapce的具体应用场景吗?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

14-深入解析Pod对象(一):基本概念

你好,我是张磊。今天我和你分享的主题是:深入解析Pod对象之基本概念。

在上一篇文章中,我详细介绍了Pod这个Kubernetes项目中最重要的概念。而在今天这篇文章中,我会和你分享Pod对象的更多细节。

现在,你已经非常清楚:Pod,而不是容器,才是Kubernetes项目中的最小编排单位。将这个设计落实到API对象上,容器(Container)就成了Pod属性里的一个普通的字段。那么,一个很自然的问题就是:到底哪些属性属于Pod对象,而又有哪些属性属于Container呢?

要彻底理解这个问题,你就一定要牢记我在上一篇文章中提到的一个结论:Pod扮演的是传统部署环境里“虚拟机”的角色。这样的设计,是为了使用户从传统环境(虚拟机环境)向Kubernetes(容器环境)的迁移,更加平滑。

而如果你能把Pod看成传统环境里的“机器”、把容器看作是运行在这个“机器”里的“用户程序”,那么很多关于Pod对象的设计就非常容易理解了。

比如,凡是调度、网络、存储,以及安全相关的属性,基本上是Pod 级别的。

这些属性的共同特征是,它们描述的是“机器”这个整体,而不是里面运行的“程序”。比如,配置这个“机器”的网卡(即:Pod的网络定义),配置这个“机器”的磁盘(即:Pod的存储定义),配置这个“机器”的防火墙(即:Pod的安全定义)。更不用说,这台“机器”运行在哪个服务器之上(即:Pod的调度)。

接下来,我就先为你介绍Pod中几个重要字段的含义和用法。

NodeSelector:是一个供用户将Pod与Node进行绑定的字段,用法如下所示:

apiVersion: v1
kind: Pod
...
spec:
 nodeSelector:
   disktype: ssd

这样的一个配置,意味着这个Pod永远只能运行在携带了“disktype: ssd”标签(Label)的节点上;否则,它将调度失败。

NodeName:一旦Pod的这个字段被赋值,Kubernetes项目就会被认为这个Pod已经经过了调度,调度的结果就是赋值的节点名字。所以,这个字段一般由调度器负责设置,但用户也可以设置它来“骗过”调度器,当然这个做法一般是在测试或者调试的时候才会用到。

HostAliases:定义了Pod的hosts文件(比如/etc/hosts)里的内容,用法如下:

apiVersion: v1
kind: Pod
...
spec:
  hostAliases:
  - ip: "10.1.2.3"
    hostnames:
    - "foo.remote"
    - "bar.remote"
...

在这个Pod的YAML文件中,我设置了一组IP和hostname的数据。这样,这个Pod启动后,/etc/hosts文件的内容将如下所示:

cat /etc/hosts
# Kubernetes-managed hosts file.
127.0.0.1 localhost
...
10.244.135.10 hostaliases-pod
10.1.2.3 foo.remote
10.1.2.3 bar.remote

其中,最下面两行记录,就是我通过HostAliases字段为Pod设置的。需要指出的是,在Kubernetes项目中,如果要设置hosts文件里的内容,一定要通过这种方法。否则,如果直接修改了hosts文件的话,在Pod被删除重建之后,kubelet会自动覆盖掉被修改的内容。

除了上述跟“机器”相关的配置外,你可能也会发现,凡是跟容器的Linux Namespace相关的属性,也一定是Pod 级别的。这个原因也很容易理解:Pod的设计,就是要让它里面的容器尽可能多地共享Linux Namespace,仅保留必要的隔离和限制能力。这样,Pod模拟出的效果,就跟虚拟机里程序间的关系非常类似了。

举个例子,在下面这个Pod的YAML文件中,我定义了shareProcessNamespace=true:

apiVersion: v1
kind: Pod
metadata:
  name: nginx
spec:
  shareProcessNamespace: true
  containers:
  - name: nginx
    image: nginx
  - name: shell
    image: busybox
    stdin: true
    tty: true

这就意味着这个Pod里的容器要共享PID Namespace。

而在这个YAML文件中,我还定义了两个容器:一个是nginx容器,一个是开启了tty和stdin的shell容器。

我在前面介绍容器基础时,曾经讲解过什么是tty和stdin。而在Pod的YAML文件里声明开启它们俩,其实等同于设置了docker run里的-it(-i即stdin,-t即tty)参数。

如果你还是不太理解它们俩的作用的话,可以直接认为tty就是Linux给用户提供的一个常驻小程序,用于接收用户的标准输入,返回操作系统的标准输出。当然,为了能够在tty中输入信息,你还需要同时开启stdin(标准输入流)。

于是,这个Pod被创建后,你就可以使用shell容器的tty跟这个容器进行交互了。我们一起实践一下:

$ kubectl create -f nginx.yaml

接下来,我们使用kubectl attach命令,连接到shell容器的tty上:

$ kubectl attach -it nginx -c shell

这样,我们就可以在shell容器里执行ps指令,查看所有正在运行的进程:

$ kubectl attach -it nginx -c shell
/ # ps ax
PID   USER     TIME  COMMAND
    1 root      0:00 /pause
    8 root      0:00 nginx: master process nginx -g daemon off;
   14 101       0:00 nginx: worker process
   15 root      0:00 sh
   21 root      0:00 ps ax

可以看到,在这个容器里,我们不仅可以看到它本身的ps ax指令,还可以看到nginx容器的进程,以及Infra容器的/pause进程。这就意味着,整个Pod里的每个容器的进程,对于所有容器来说都是可见的:它们共享了同一个PID Namespace。

类似地,凡是Pod中的容器要共享宿主机的Namespace,也一定是Pod级别的定义,比如:

apiVersion: v1
kind: Pod
metadata:
  name: nginx
spec:
  hostNetwork: true
  hostIPC: true
  hostPID: true
  containers:
  - name: nginx
    image: nginx
  - name: shell
    image: busybox
    stdin: true
    tty: true

在这个Pod中,我定义了共享宿主机的Network、IPC和PID Namespace。这就意味着,这个Pod里的所有容器,会直接使用宿主机的网络、直接与宿主机进行IPC通信、看到宿主机里正在运行的所有进程。

当然,除了这些属性,Pod里最重要的字段当属“Containers”了。而在上一篇文章中,我还介绍过“Init Containers”。其实,这两个字段都属于Pod对容器的定义,内容也完全相同,只是Init Containers的生命周期,会先于所有的Containers,并且严格按照定义的顺序执行。

Kubernetes项目中对Container的定义,和Docker相比并没有什么太大区别。我在前面的容器技术概念入门系列文章中,和你分享的Image(镜像)、Command(启动命令)、workingDir(容器的工作目录)、Ports(容器要开发的端口),以及volumeMounts(容器要挂载的Volume)都是构成Kubernetes项目中Container的主要字段。不过在这里,还有这么几个属性值得你额外关注。

首先,是ImagePullPolicy字段。它定义了镜像拉取的策略。而它之所以是一个Container级别的属性,是因为容器镜像本来就是Container定义中的一部分。

ImagePullPolicy的值默认是Always,即每次创建Pod都重新拉取一次镜像。另外,当容器的镜像是类似于nginx或者nginx:latest这样的名字时,ImagePullPolicy也会被认为Always。

而如果它的值被定义为Never或者IfNotPresent,则意味着Pod永远不会主动拉取这个镜像,或者只在宿主机上不存在这个镜像时才拉取。

其次,是Lifecycle字段。它定义的是Container Lifecycle Hooks。顾名思义,Container Lifecycle Hooks的作用,是在容器状态发生变化时触发一系列“钩子”。我们来看这样一个例子:

apiVersion: v1
kind: Pod
metadata:
  name: lifecycle-demo
spec:
  containers:
  - name: lifecycle-demo-container
    image: nginx
    lifecycle:
      postStart:
        exec:
          command: ["/bin/sh", "-c", "echo Hello from the postStart handler > /usr/share/message"]
      preStop:
        exec:
          command: ["/usr/sbin/nginx","-s","quit"]

这是一个来自Kubernetes官方文档的Pod的YAML文件。它其实非常简单,只是定义了一个nginx镜像的容器。不过,在这个YAML文件的容器(Containers)部分,你会看到这个容器分别设置了一个postStart和preStop参数。这是什么意思呢?

先说postStart吧。它指的是,在容器启动后,立刻执行一个指定的操作。需要明确的是,postStart定义的操作,虽然是在Docker容器ENTRYPOINT执行之后,但它并不严格保证顺序。也就是说,在postStart启动时,ENTRYPOINT有可能还没有结束。

当然,如果postStart执行超时或者错误,Kubernetes会在该Pod的Events中报出该容器启动失败的错误信息,导致Pod也处于失败的状态。

而类似地,preStop发生的时机,则是容器被杀死之前(比如,收到了SIGKILL信号)。而需要明确的是,preStop操作的执行,是同步的。所以,它会阻塞当前的容器杀死流程,直到这个Hook定义操作完成之后,才允许容器被杀死,这跟postStart不一样。

所以,在这个例子中,我们在容器成功启动之后,在/usr/share/message里写入了一句“欢迎信息”(即postStart定义的操作)。而在这个容器被删除之前,我们则先调用了nginx的退出指令(即preStop定义的操作),从而实现了容器的“优雅退出”。

在熟悉了Pod以及它的Container部分的主要字段之后,我再和你分享一下这样一个的Pod对象在Kubernetes中的生命周期

Pod生命周期的变化,主要体现在Pod API对象的Status部分,这是它除了Metadata和Spec之外的第三个重要字段。其中,pod.status.phase,就是Pod的当前状态,它有如下几种可能的情况:

  1. Pending。这个状态意味着,Pod的YAML文件已经提交给了Kubernetes,API对象已经被创建并保存在Etcd当中。但是,这个Pod里有些容器因为某种原因而不能被顺利创建。比如,调度不成功。

  2. Running。这个状态下,Pod已经调度成功,跟一个具体的节点绑定。它包含的容器都已经创建成功,并且至少有一个正在运行中。

  3. Succeeded。这个状态意味着,Pod里的所有容器都正常运行完毕,并且已经退出了。这种情况在运行一次性任务时最为常见。

  4. Failed。这个状态下,Pod里至少有一个容器以不正常的状态(非0的返回码)退出。这个状态的出现,意味着你得想办法Debug这个容器的应用,比如查看Pod的Events和日志。

  5. Unknown。这是一个异常状态,意味着Pod的状态不能持续地被kubelet汇报给kube-apiserver,这很有可能是主从节点(Master和Kubelet)间的通信出现了问题。

更进一步地,Pod对象的Status字段,还可以再细分出一组Conditions。这些细分状态的值包括:PodScheduled、Ready、Initialized,以及Unschedulable。它们主要用于描述造成当前Status的具体原因是什么。

比如,Pod当前的Status是Pending,对应的Condition是Unschedulable,这就意味着它的调度出现了问题。

而其中,Ready这个细分状态非常值得我们关注:它意味着Pod不仅已经正常启动(Running状态),而且已经可以对外提供服务了。这两者之间(Running和Ready)是有区别的,你不妨仔细思考一下。

Pod的这些状态信息,是我们判断应用运行情况的重要标准,尤其是Pod进入了非“Running”状态后,你一定要能迅速做出反应,根据它所代表的异常情况开始跟踪和定位,而不是去手忙脚乱地查阅文档。

总结

在今天这篇文章中,我详细讲解了Pod API对象,介绍了Pod的核心使用方法,并分析了Pod和Container在字段上的异同。希望这些讲解能够帮你更好地理解和记忆Pod YAML中的核心字段,以及这些字段的准确含义。

实际上,Pod API对象是整个Kubernetes体系中最核心的一个概念,也是后面我讲解各种控制器时都要用到的。

在学习完这篇文章后,我希望你能仔细阅读$GOPATH/src/k8s.io/kubernetes/vendor/k8s.io/api/core/v1/types.go里,type Pod struct ,尤其是PodSpec部分的内容。争取做到下次看到一个Pod的YAML文件时,不再需要查阅文档,就能做到把常用字段及其作用信手拈来。

而在下一篇文章中,我会通过大量的实践,帮助你巩固和进阶关于Pod API对象核心字段的使用方法,敬请期待吧。

思考题

你能否举出一些Pod(即容器)的状态是Running,但是应用其实已经停止服务的例子?相信Java Web开发者的亲身体会会比较多吧。

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

15-深入解析Pod对象(二):使用进阶

你好,我是张磊。今天我和你分享的主题是:深入解析Pod对象之使用进阶。

在上一篇文章中,我深入解析了Pod的API对象,讲解了Pod和Container的关系。

作为Kubernetes项目里最核心的编排对象,Pod携带的信息非常丰富。其中,资源定义(比如CPU、内存等),以及调度相关的字段,我会在后面专门讲解调度器时再进行深入的分析。在本篇,我们就先从一种特殊的Volume开始,来帮助你更加深入地理解Pod对象各个重要字段的含义。

这种特殊的Volume,叫作Projected Volume,你可以把它翻译为“投射数据卷”。

备注:Projected Volume是Kubernetes v1.11之后的新特性

这是什么意思呢?

在Kubernetes中,有几种特殊的Volume,它们存在的意义不是为了存放容器里的数据,也不是用来进行容器和宿主机之间的数据交换。这些特殊Volume的作用,是为容器提供预先定义好的数据。所以,从容器的角度来看,这些Volume里的信息就是仿佛是被Kubernetes“投射”(Project)进入容器当中的。这正是Projected Volume的含义。

到目前为止,Kubernetes支持的Projected Volume一共有四种:

  1. Secret;

  2. ConfigMap;

  3. Downward API;

  4. ServiceAccountToken。

在今天这篇文章中,我首先和你分享的是Secret。它的作用,是帮你把Pod想要访问的加密数据,存放到Etcd中。然后,你就可以通过在Pod的容器里挂载Volume的方式,访问到这些Secret里保存的信息了。

Secret最典型的使用场景,莫过于存放数据库的Credential信息,比如下面这个例子:

apiVersion: v1
kind: Pod
metadata:
  name: test-projected-volume
spec:
  containers:
  - name: test-secret-volume
    image: busybox
    args:
    - sleep
    - "86400"
    volumeMounts:
    - name: mysql-cred
      mountPath: "/projected-volume"
      readOnly: true
  volumes:
  - name: mysql-cred
    projected:
      sources:
      - secret:
          name: user
      - secret:
          name: pass

在这个Pod中,我定义了一个简单的容器。它声明挂载的Volume,并不是常见的emptyDir或者hostPath类型,而是projected类型。而这个 Volume的数据来源(sources),则是名为user和pass的Secret对象,分别对应的是数据库的用户名和密码。

这里用到的数据库的用户名、密码,正是以Secret对象的方式交给Kubernetes保存的。完成这个操作的指令,如下所示:

$ cat ./username.txt
admin
$ cat ./password.txt
c1oudc0w!

$ kubectl create secret generic user --from-file=./username.txt
$ kubectl create secret generic pass --from-file=./password.txt

其中,username.txt和password.txt文件里,存放的就是用户名和密码;而user和pass,则是我为Secret对象指定的名字。而我想要查看这些Secret对象的话,只要执行一条kubectl get命令就可以了:

$ kubectl get secrets
NAME           TYPE                                DATA      AGE
user          Opaque                                1         51s
pass          Opaque                                1         51s

当然,除了使用kubectl create secret指令外,我也可以直接通过编写YAML文件的方式来创建这个Secret对象,比如:

apiVersion: v1
kind: Secret
metadata:
  name: mysecret
type: Opaque
data:
  user: YWRtaW4=
  pass: MWYyZDFlMmU2N2Rm

可以看到,通过编写YAML文件创建出来的Secret对象只有一个。但它的data字段,却以Key-Value的格式保存了两份Secret数据。其中,“user”就是第一份数据的Key,“pass”是第二份数据的Key。

需要注意的是,Secret对象要求这些数据必须是经过Base64转码的,以免出现明文密码的安全隐患。这个转码操作也很简单,比如:

$ echo -n 'admin' | base64
YWRtaW4=
$ echo -n '1f2d1e2e67df' | base64
MWYyZDFlMmU2N2Rm

这里需要注意的是,像这样创建的Secret对象,它里面的内容仅仅是经过了转码,而并没有被加密。在真正的生产环境中,你需要在Kubernetes中开启Secret的加密插件,增强数据的安全性。关于开启Secret加密插件的内容,我会在后续专门讲解Secret的时候,再做进一步说明。

接下来,我们尝试一下创建这个Pod:

$ kubectl create -f test-projected-volume.yaml

当Pod变成Running状态之后,我们再验证一下这些Secret对象是不是已经在容器里了:

$ kubectl exec -it test-projected-volume -- /bin/sh
$ ls /projected-volume/
user
pass
$ cat /projected-volume/user
root
$ cat /projected-volume/pass
1f2d1e2e67df

从返回结果中,我们可以看到,保存在Etcd里的用户名和密码信息,已经以文件的形式出现在了容器的Volume目录里。而这个文件的名字,就是kubectl create secret指定的Key,或者说是Secret对象的data字段指定的Key。

更重要的是,像这样通过挂载方式进入到容器里的Secret,一旦其对应的Etcd里的数据被更新,这些Volume里的文件内容,同样也会被更新。其实,这是kubelet组件在定时维护这些Volume。

需要注意的是,这个更新可能会有一定的延时。所以在编写应用程序时,在发起数据库连接的代码处写好重试和超时的逻辑,绝对是个好习惯。

与Secret类似的是ConfigMap,它与Secret的区别在于,ConfigMap保存的是不需要加密的、应用所需的配置信息。而ConfigMap的用法几乎与Secret完全相同:你可以使用kubectl create configmap从文件或者目录创建ConfigMap,也可以直接编写ConfigMap对象的YAML文件。

比如,一个Java应用所需的配置文件(.properties文件),就可以通过下面这样的方式保存在ConfigMap里:

# .properties文件的内容
$ cat example/ui.properties
color.good=purple
color.bad=yellow
allow.textmode=true
how.nice.to.look=fairlyNice

# 从.properties文件创建ConfigMap
$ kubectl create configmap ui-config --from-file=example/ui.properties

# 查看这个ConfigMap里保存的信息(data)
$ kubectl get configmaps ui-config -o yaml
apiVersion: v1
data:
  ui.properties: |
    color.good=purple
    color.bad=yellow
    allow.textmode=true
    how.nice.to.look=fairlyNice
kind: ConfigMap
metadata:
  name: ui-config
  ...

备注:kubectl get -o yaml这样的参数,会将指定的Pod API对象以YAML的方式展示出来。

接下来是Downward API,它的作用是:让Pod里的容器能够直接获取到这个Pod API对象本身的信息。

举个例子:

apiVersion: v1
kind: Pod
metadata:
  name: test-downwardapi-volume
  labels:
    zone: us-est-coast
    cluster: test-cluster1
    rack: rack-22
spec:
  containers:
    - name: client-container
      image: k8s.gcr.io/busybox
      command: ["sh", "-c"]
      args:
      - while true; do
          if [[ -e /etc/podinfo/labels ]]; then
            echo -en '\n\n'; cat /etc/podinfo/labels; fi;
          sleep 5;
        done;
      volumeMounts:
        - name: podinfo
          mountPath: /etc/podinfo
          readOnly: false
  volumes:
    - name: podinfo
      projected:
        sources:
        - downwardAPI:
            items:
              - path: "labels"
                fieldRef:
                  fieldPath: metadata.labels

在这个Pod的YAML文件中,我定义了一个简单的容器,声明了一个projected类型的Volume。只不过这次Volume的数据来源,变成了Downward API。而这个Downward API Volume,则声明了要暴露Pod的metadata.labels信息给容器。

通过这样的声明方式,当前Pod的Labels字段的值,就会被Kubernetes自动挂载成为容器里的/etc/podinfo/labels文件。

而这个容器的启动命令,则是不断打印出/etc/podinfo/labels里的内容。所以,当我创建了这个Pod之后,就可以通过kubectl logs指令,查看到这些Labels字段被打印出来,如下所示:

$ kubectl create -f dapi-volume.yaml
$ kubectl logs test-downwardapi-volume
cluster="test-cluster1"
rack="rack-22"
zone="us-est-coast"

目前,Downward API支持的字段已经非常丰富了,比如:

1. 使用fieldRef可以声明使用:
spec.nodeName - 宿主机名字
status.hostIP - 宿主机IP
metadata.name - Pod的名字
metadata.namespace - Pod的Namespace
status.podIP - Pod的IP
spec.serviceAccountName - Pod的Service Account的名字
metadata.uid - Pod的UID
metadata.labels['<KEY>'] - 指定<KEY>的Label值
metadata.annotations['<KEY>'] - 指定<KEY>的Annotation值
metadata.labels - Pod的所有Label
metadata.annotations - Pod的所有Annotation

2. 使用resourceFieldRef可以声明使用:
容器的CPU limit
容器的CPU request
容器的memory limit
容器的memory request

上面这个列表的内容,随着Kubernetes项目的发展肯定还会不断增加。所以这里列出来的信息仅供参考,你在使用Downward API时,还是要记得去查阅一下官方文档。

不过,需要注意的是,Downward API能够获取到的信息,一定是Pod里的容器进程启动之前就能够确定下来的信息。而如果你想要获取Pod容器运行后才会出现的信息,比如,容器进程的PID,那就肯定不能使用Downward API了,而应该考虑在Pod里定义一个sidecar容器。

其实,Secret、ConfigMap,以及Downward API这三种Projected Volume定义的信息,大多还可以通过环境变量的方式出现在容器里。但是,通过环境变量获取这些信息的方式,不具备自动更新的能力。所以,一般情况下,我都建议你使用Volume文件的方式获取这些信息。

在明白了Secret之后,我再为你讲解Pod中一个与它密切相关的概念:Service Account

相信你一定有过这样的想法:我现在有了一个Pod,我能不能在这个Pod里安装一个Kubernetes的Client,这样就可以从容器里直接访问并且操作这个Kubernetes的API了呢?

这当然是可以的。

不过,你首先要解决API Server的授权问题。

Service Account对象的作用,就是Kubernetes系统内置的一种“服务账户”,它是Kubernetes进行权限分配的对象。比如,Service Account A,可以只被允许对Kubernetes API进行GET操作,而Service Account B,则可以有Kubernetes API的所有操作权限。

像这样的Service Account的授权信息和文件,实际上保存在它所绑定的一个特殊的Secret对象里的。这个特殊的Secret对象,就叫作ServiceAccountToken。任何运行在Kubernetes集群上的应用,都必须使用这个ServiceAccountToken里保存的授权信息,也就是Token,才可以合法地访问API Server。

所以说,Kubernetes项目的Projected Volume其实只有三种,因为第四种ServiceAccountToken,只是一种特殊的Secret而已。

另外,为了方便使用,Kubernetes已经为你提供了一个默认“服务账户”(default Service Account)。并且,任何一个运行在Kubernetes里的Pod,都可以直接使用这个默认的Service Account,而无需显示地声明挂载它。

这是如何做到的呢?

当然还是靠Projected Volume机制。

如果你查看一下任意一个运行在Kubernetes集群里的Pod,就会发现,每一个Pod,都已经自动声明一个类型是Secret、名为default-token-xxxx的Volume,然后 自动挂载在每个容器的一个固定目录上。比如:

$ kubectl describe pod nginx-deployment-5c678cfb6d-lg9lw
Containers:
...
  Mounts:
    /var/run/secrets/kubernetes.io/serviceaccount from default-token-s8rbq (ro)
Volumes:
  default-token-s8rbq:
  Type:       Secret (a volume populated by a Secret)
  SecretName:  default-token-s8rbq
  Optional:    false

这个Secret类型的Volume,正是默认Service Account对应的ServiceAccountToken。所以说,Kubernetes其实在每个Pod创建的时候,自动在它的spec.volumes部分添加上了默认ServiceAccountToken的定义,然后自动给每个容器加上了对应的volumeMounts字段。这个过程对于用户来说是完全透明的。

这样,一旦Pod创建完成,容器里的应用就可以直接从这个默认ServiceAccountToken的挂载目录里访问到授权信息和文件。这个容器内的路径在Kubernetes里是固定的,即:/var/run/secrets/kubernetes.io/serviceaccount ,而这个Secret类型的Volume里面的内容如下所示:

$ ls /var/run/secrets/kubernetes.io/serviceaccount
ca.crt namespace  token

所以,你的应用程序只要直接加载这些授权文件,就可以访问并操作Kubernetes API了。而且,如果你使用的是Kubernetes官方的Client包(k8s.io/client-go)的话,它还可以自动加载这个目录下的文件,你不需要做任何配置或者编码操作。

这种把Kubernetes客户端以容器的方式运行在集群里,然后使用default Service Account自动授权的方式,被称作“InClusterConfig”,也是我最推荐的进行Kubernetes API编程的授权方式。

当然,考虑到自动挂载默认ServiceAccountToken的潜在风险,Kubernetes允许你设置默认不为Pod里的容器自动挂载这个Volume。

除了这个默认的Service Account外,我们很多时候还需要创建一些我们自己定义的Service Account,来对应不同的权限设置。这样,我们的Pod里的容器就可以通过挂载这些Service Account对应的ServiceAccountToken,来使用这些自定义的授权信息。在后面讲解为Kubernetes开发插件的时候,我们将会实践到这个操作。

接下来,我们再来看Pod的另一个重要的配置:容器健康检查和恢复机制。

在Kubernetes中,你可以为Pod里的容器定义一个健康检查“探针”(Probe)。这样,kubelet就会根据这个Probe的返回值决定这个容器的状态,而不是直接以容器镜像是否运行(来自Docker返回的信息)作为依据。这种机制,是生产环境中保证应用健康存活的重要手段。

我们一起来看一个Kubernetes文档中的例子。

apiVersion: v1
kind: Pod
metadata:
  labels:
    test: liveness
  name: test-liveness-exec
spec:
  containers:
  - name: liveness
    image: busybox
    args:
    - /bin/sh
    - -c
    - touch /tmp/healthy; sleep 30; rm -rf /tmp/healthy; sleep 600
    livenessProbe:
      exec:
        command:
        - cat
        - /tmp/healthy
      initialDelaySeconds: 5
      periodSeconds: 5

在这个Pod中,我们定义了一个有趣的容器。它在启动之后做的第一件事,就是在/tmp目录下创建了一个healthy文件,以此作为自己已经正常运行的标志。而30 s过后,它会把这个文件删除掉。

与此同时,我们定义了一个这样的livenessProbe(健康检查)。它的类型是exec,这意味着,它会在容器启动后,在容器里面执行一条我们指定的命令,比如:“cat /tmp/healthy”。这时,如果这个文件存在,这条命令的返回值就是0,Pod就会认为这个容器不仅已经启动,而且是健康的。这个健康检查,在容器启动5 s后开始执行(initialDelaySeconds: 5),每5 s执行一次(periodSeconds: 5)。

现在,让我们来具体实践一下这个过程

首先,创建这个Pod:

$ kubectl create -f test-liveness-exec.yaml

然后,查看这个Pod的状态:

$ kubectl get pod
NAME                READY     STATUS    RESTARTS   AGE
test-liveness-exec   1/1       Running   0          10s

可以看到,由于已经通过了健康检查,这个Pod就进入了Running状态。

而30 s之后,我们再查看一下Pod的Events:

$ kubectl describe pod test-liveness-exec

你会发现,这个Pod在Events报告了一个异常:

FirstSeen LastSeen    Count   From            SubobjectPath           Type        Reason      Message
--------- --------    -----   ----            -------------           --------    ------      -------
2s        2s      1   {kubelet worker0}   spec.containers{liveness}   Warning     Unhealthy   Liveness probe failed: cat: can't open '/tmp/healthy': No such file or directory

显然,这个健康检查探查到/tmp/healthy已经不存在了,所以它报告容器是不健康的。那么接下来会发生什么呢?

我们不妨再次查看一下这个Pod的状态:

$ kubectl get pod test-liveness-exec
NAME           READY     STATUS    RESTARTS   AGE
liveness-exec   1/1       Running   1          1m

这时我们发现,Pod并没有进入Failed状态,而是保持了Running状态。这是为什么呢?

其实,如果你注意到RESTARTS字段从0到1的变化,就明白原因了:这个异常的容器已经被Kubernetes重启了。在这个过程中,Pod保持Running状态不变。

需要注意的是:Kubernetes中并没有Docker的Stop语义。所以虽然是Restart(重启),但实际却是重新创建了容器。

这个功能就是Kubernetes里的Pod恢复机制,也叫restartPolicy。它是Pod的Spec部分的一个标准字段(pod.spec.restartPolicy),默认值是Always,即:任何时候这个容器发生了异常,它一定会被重新创建。

但一定要强调的是,Pod的恢复过程,永远都是发生在当前节点上,而不会跑到别的节点上去。事实上,一旦一个Pod与一个节点(Node)绑定,除非这个绑定发生了变化(pod.spec.node字段被修改),否则它永远都不会离开这个节点。这也就意味着,如果这个宿主机宕机了,这个Pod也不会主动迁移到其他节点上去。

而如果你想让Pod出现在其他的可用节点上,就必须使用Deployment这样的“控制器”来管理Pod,哪怕你只需要一个Pod副本。这就是我在第12篇文章《牛刀小试:我的第一个容器化应用》最后给你留的思考题的答案,即一个单Pod的Deployment与一个Pod最主要的区别。

而作为用户,你还可以通过设置restartPolicy,改变Pod的恢复策略。除了Always,它还有OnFailure和Never两种情况:

  • Always:在任何情况下,只要容器不在运行状态,就自动重启容器;
  • OnFailure: 只在容器 异常时才自动重启容器;
  • Never: 从来不重启容器。

在实际使用时,我们需要根据应用运行的特性,合理设置这三种恢复策略。

比如,一个Pod,它只计算1+1=2,计算完成输出结果后退出,变成Succeeded状态。这时,你如果再用restartPolicy=Always强制重启这个Pod的容器,就没有任何意义了。

而如果你要关心这个容器退出后的上下文环境,比如容器退出后的日志、文件和目录,就需要将restartPolicy设置为Never。因为一旦容器被自动重新创建,这些内容就有可能丢失掉了(被垃圾回收了)。

值得一提的是,Kubernetes的官方文档,把restartPolicy和Pod里容器的状态,以及Pod状态的对应关系,总结了非常复杂的一大堆情况。实际上,你根本不需要死记硬背这些对应关系,只要记住如下两个基本的设计原理即可:

  1. 只要Pod的restartPolicy指定的策略允许重启异常的容器(比如:Always),那么这个Pod就会保持Running状态,并进行容器重启。否则,Pod就会进入Failed状态 。

  2. 对于包含多个容器的Pod,只有它里面所有的容器都进入异常状态后,Pod才会进入Failed状态。在此之前,Pod都是Running状态。此时,Pod的READY字段会显示正常容器的个数,比如:

$ kubectl get pod test-liveness-exec
NAME           READY     STATUS    RESTARTS   AGE
liveness-exec   0/1       Running   1          1m

所以,假如一个Pod里只有一个容器,然后这个容器异常退出了。那么,只有当restartPolicy=Never时,这个Pod才会进入Failed状态。而其他情况下,由于Kubernetes都可以重启这个容器,所以Pod的状态保持Running不变。

而如果这个Pod有多个容器,仅有一个容器异常退出,它就始终保持Running状态,哪怕即使restartPolicy=Never。只有当所有容器也异常退出之后,这个Pod才会进入Failed状态。

其他情况,都可以以此类推出来。

现在,我们一起回到前面提到的livenessProbe上来。

除了在容器中执行命令外,livenessProbe也可以定义为发起HTTP或者TCP请求的方式,定义格式如下:

...
livenessProbe:
     httpGet:
       path: /healthz
       port: 8080
       httpHeaders:
       - name: X-Custom-Header
         value: Awesome
       initialDelaySeconds: 3
       periodSeconds: 3
    ...
    livenessProbe:
      tcpSocket:
        port: 8080
      initialDelaySeconds: 15
      periodSeconds: 20

所以,你的Pod其实可以暴露一个健康检查URL(比如/healthz),或者直接让健康检查去检测应用的监听端口。这两种配置方法,在Web服务类的应用中非常常用。

在Kubernetes的Pod中,还有一个叫readinessProbe的字段。虽然它的用法与livenessProbe类似,但作用却大不一样。readinessProbe检查结果的成功与否,决定的这个Pod是不是能被通过Service的方式访问到,而并不影响Pod的生命周期。这部分内容,我会在讲解Service时重点介绍。

在讲解了这么多字段之后,想必你对Pod对象的语义和描述能力,已经有了一个初步的感觉。

这时,你有没有产生这样一个想法:Pod的字段这么多,我又不可能全记住,Kubernetes能不能自动给Pod填充某些字段呢?

这个需求实际上非常实用。比如,开发人员只需要提交一个基本的、非常简单的Pod YAML,Kubernetes就可以自动给对应的Pod对象加上其他必要的信息,比如labels,annotations,volumes等等。而这些信息,可以是运维人员事先定义好的。

这么一来,开发人员编写Pod YAML的门槛,就被大大降低了。

所以,这个叫作PodPreset(Pod预设置)的功能 已经出现在了v1.11版本的Kubernetes中。

举个例子,现在开发人员编写了如下一个 pod.yaml文件:

apiVersion: v1
kind: Pod
metadata:
  name: website
  labels:
    app: website
    role: frontend
spec:
  containers:
    - name: website
      image: nginx
      ports:
        - containerPort: 80

作为Kubernetes的初学者,你肯定眼前一亮:这不就是我最擅长编写的、最简单的Pod嘛。没错,这个YAML文件里的字段,想必你现在闭着眼睛也能写出来。

可是,如果运维人员看到了这个Pod,他一定会连连摇头:这种Pod在生产环境里根本不能用啊!

所以,这个时候,运维人员就可以定义一个PodPreset对象。在这个对象中,凡是他想在开发人员编写的Pod里追加的字段,都可以预先定义好。比如这个preset.yaml:

apiVersion: settings.k8s.io/v1alpha1
kind: PodPreset
metadata:
  name: allow-database
spec:
  selector:
    matchLabels:
      role: frontend
  env:
    - name: DB_PORT
      value: "6379"
  volumeMounts:
    - mountPath: /cache
      name: cache-volume
  volumes:
    - name: cache-volume
      emptyDir: {}

在这个PodPreset的定义中,首先是一个selector。这就意味着后面这些追加的定义,只会作用于selector所定义的、带有“role: frontend”标签的Pod对象,这就可以防止“误伤”。

然后,我们定义了一组Pod的Spec里的标准字段,以及对应的值。比如,env里定义了DB_PORT这个环境变量,volumeMounts定义了容器Volume的挂载目录,volumes定义了一个emptyDir的Volume。

接下来,我们假定运维人员先创建了这个PodPreset,然后开发人员才创建Pod:

$ kubectl create -f preset.yaml
$ kubectl create -f pod.yaml

这时,Pod运行起来之后,我们查看一下这个Pod的API对象:

$ kubectl get pod website -o yaml
apiVersion: v1
kind: Pod
metadata:
  name: website
  labels:
    app: website
    role: frontend
  annotations:
    podpreset.admission.kubernetes.io/podpreset-allow-database: "resource version"
spec:
  containers:
    - name: website
      image: nginx
      volumeMounts:
        - mountPath: /cache
          name: cache-volume
      ports:
        - containerPort: 80
      env:
        - name: DB_PORT
          value: "6379"
  volumes:
    - name: cache-volume
      emptyDir: {}

这个时候,我们就可以清楚地看到,这个Pod里多了新添加的labels、env、volumes和volumeMount的定义,它们的配置跟PodPreset的内容一样。此外,这个Pod还被自动加上了一个annotation表示这个Pod对象被PodPreset改动过。

需要说明的是,PodPreset里定义的内容,只会在Pod API对象被创建之前追加在这个对象本身上,而不会影响任何Pod的控制器的定义。

比如,我们现在提交的是一个nginx-deployment,那么这个Deployment对象本身是永远不会被PodPreset改变的,被修改的只是这个Deployment创建出来的所有Pod。这一点请务必区分清楚。

这里有一个问题:如果你定义了同时作用于一个Pod对象的多个PodPreset,会发生什么呢?

实际上,Kubernetes项目会帮你合并(Merge)这两个PodPreset要做的修改。而如果它们要做的修改有冲突的话,这些冲突字段就不会被修改。

总结

在今天这篇文章中,我和你详细介绍了Pod对象更高阶的使用方法,希望通过对这些实例的讲解,你可以更深入地理解Pod API对象的各个字段。

而在学习这些字段的同时,你还应该认真体会一下Kubernetes“一切皆对象”的设计思想:比如应用是Pod对象,应用的配置是ConfigMap对象,应用要访问的密码则是Secret对象。

所以,也就自然而然地有了PodPreset这样专门用来对Pod进行批量化、自动化修改的工具对象。在后面的内容中,我会为你讲解更多的这种对象,还会和你介绍Kubernetes项目如何围绕着这些对象进行容器编排。

在本专栏中,Pod对象相关的知识点非常重要,它是接下来Kubernetes能够描述和编排各种复杂应用的基石所在,希望你能够继续多实践、多体会。

思考题

在没有Kubernetes的时候,你是通过什么方法进行应用的健康检查的?Kubernetes的livenessProbe和readinessProbe提供的几种探测机制,是否能满足你的需求?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

16-编排其实很简单:谈谈“控制器”模型

你好,我是张磊。今天我和你分享的主题是:编排其实很简单之谈谈“控制器”模型。

在上一篇文章中,我和你详细介绍了Pod的用法,讲解了Pod这个API对象的各个字段。而接下来,我们就一起来看看“编排”这个Kubernetes项目最核心的功能吧。

实际上,你可能已经有所感悟:Pod这个看似复杂的API对象,实际上就是对容器的进一步抽象和封装而已。

说得更形象些,“容器”镜像虽然好用,但是容器这样一个“沙盒”的概念,对于描述应用来说,还是太过简单了。这就好比,集装箱固然好用,但是如果它四面都光秃秃的,吊车还怎么把这个集装箱吊起来并摆放好呢?

所以,Pod对象,其实就是容器的升级版。它对容器进行了组合,添加了更多的属性和字段。这就好比给集装箱四面安装了吊环,使得Kubernetes这架“吊车”,可以更轻松地操作它。

而Kubernetes操作这些“集装箱”的逻辑,都由控制器(Controller)完成。在前面的第12篇文章《牛刀小试:我的第一个容器化应用》中,我们曾经使用过Deployment这个最基本的控制器对象。

现在,我们一起来回顾一下这个名叫nginx-deployment的例子:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
spec:
  selector:
    matchLabels:
      app: nginx
  replicas: 2
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.7.9
        ports:
        - containerPort: 80

这个Deployment定义的编排动作非常简单,即:确保携带了app=nginx标签的Pod的个数,永远等于spec.replicas指定的个数,即2个。

这就意味着,如果在这个集群中,携带app=nginx标签的Pod的个数大于2的时候,就会有旧的Pod被删除;反之,就会有新的Pod被创建。

这时,你也许就会好奇:究竟是Kubernetes项目中的哪个组件,在执行这些操作呢?

我在前面介绍Kubernetes架构的时候,曾经提到过一个叫作kube-controller-manager的组件。

实际上,这个组件,就是一系列控制器的集合。我们可以查看一下Kubernetes项目的pkg/controller目录:

$ cd kubernetes/pkg/controller/
$ ls -d */              
deployment/             job/                    podautoscaler/          
cloud/                  disruption/             namespace/              
replicaset/             serviceaccount/         volume/
cronjob/                garbagecollector/       nodelifecycle/          replication/            statefulset/            daemon/
...

这个目录下面的每一个控制器,都以独有的方式负责某种编排功能。而我们的Deployment,正是这些控制器中的一种。

实际上,这些控制器之所以被统一放在pkg/controller目录下,就是因为它们都遵循Kubernetes项目中的一个通用编排模式,即:控制循环(control loop)。

比如,现在有一种待编排的对象X,它有一个对应的控制器。那么,我就可以用一段Go语言风格的伪代码,为你描述这个控制循环

for {
  实际状态 := 获取集群中对象X的实际状态(Actual State)
  期望状态 := 获取集群中对象X的期望状态(Desired State)
  if 实际状态 == 期望状态{
    什么都不做
  } else {
    执行编排动作,将实际状态调整为期望状态
  }
}

在具体实现中,实际状态往往来自于Kubernetes集群本身

比如,kubelet通过心跳汇报的容器状态和节点状态,或者监控系统中保存的应用监控数据,或者控制器主动收集的它自己感兴趣的信息,这些都是常见的实际状态的来源。

而期望状态,一般来自于用户提交的YAML文件

比如,Deployment对象中Replicas字段的值。很明显,这些信息往往都保存在Etcd中。

接下来,以Deployment为例,我和你简单描述一下它对控制器模型的实现:

  1. Deployment控制器从Etcd中获取到所有携带了“app: nginx”标签的Pod,然后统计它们的数量,这就是实际状态;

  2. Deployment对象的Replicas字段的值就是期望状态;

  3. Deployment控制器将两个状态做比较,然后根据比较结果,确定是创建Pod,还是删除已有的Pod(具体如何操作Pod对象,我会在下一篇文章详细介绍)。

可以看到,一个Kubernetes对象的主要编排逻辑,实际上是在第三步的“对比”阶段完成的。

这个操作,通常被叫作调谐(Reconcile)。这个调谐的过程,则被称作“Reconcile Loop”(调谐循环)或者“Sync Loop”(同步循环)。

所以,如果你以后在文档或者社区中碰到这些词,都不要担心,它们其实指的都是同一个东西:控制循环。

而调谐的最终结果,往往都是对被控制对象的某种写操作。

比如,增加Pod,删除已有的Pod,或者更新Pod的某个字段。这也是Kubernetes项目“面向API对象编程”的一个直观体现。

其实,像Deployment这种控制器的设计原理,就是我们前面提到过的,“用一种对象管理另一种对象”的“艺术”。

其中,这个控制器对象本身,负责定义被管理对象的期望状态。比如,Deployment里的replicas=2这个字段。

而被控制对象的定义,则来自于一个“模板”。比如,Deployment里的template字段。

可以看到,Deployment这个template字段里的内容,跟一个标准的Pod对象的API定义,丝毫不差。而所有被这个Deployment管理的Pod实例,其实都是根据这个template字段的内容创建出来的。

像Deployment定义的template字段,在Kubernetes项目中有一个专有的名字,叫作PodTemplate(Pod模板)。

这个概念非常重要,因为后面我要讲解到的大多数控制器,都会使用PodTemplate来统一定义它所要管理的Pod。更有意思的是,我们还会看到其他类型的对象模板,比如Volume的模板。

至此,我们就可以对Deployment以及其他类似的控制器,做一个简单总结了:

如上图所示,类似Deployment这样的一个控制器,实际上都是由上半部分的控制器定义(包括期望状态),加上下半部分的被控制对象的模板组成的。

这就是为什么,在所有API对象的Metadata里,都有一个字段叫作ownerReference,用于保存当前这个API对象的拥有者(Owner)的信息。

那么,对于我们这个nginx-deployment来说,它创建出来的Pod的ownerReference就是nginx-deployment吗?或者说,nginx-deployment所直接控制的,就是Pod对象么?

这个问题的答案,我就留到下一篇文章时再做详细解释吧。

总结

在今天这篇文章中,我以Deployment为例,和你详细分享了Kubernetes项目如何通过一个称作“控制器模式”(controller pattern)的设计方法,来统一地实现对各种不同的对象或者资源进行的编排操作。

在后面的讲解中,我还会讲到很多不同类型的容器编排功能,比如StatefulSet、DaemonSet等等,它们无一例外地都有这样一个甚至多个控制器的存在,并遵循控制循环(control loop)的流程,完成各自的编排逻辑。

实际上,跟Deployment相似,这些控制循环最后的执行结果,要么就是创建、更新一些Pod(或者其他的API对象、资源),要么就是删除一些已经存在的Pod(或者其他的API对象、资源)。

但也正是在这个统一的编排框架下,不同的控制器可以在具体执行过程中,设计不同的业务逻辑,从而达到不同的编排效果。

这个实现思路,正是Kubernetes项目进行容器编排的核心原理。在此后讲解Kubernetes编排功能的文章中,我都会遵循这个逻辑展开,并且带你逐步领悟控制器模式在不同的容器化作业中的实现方式。

思考题

你能否说出,Kubernetes使用的这个“控制器模式”,跟我们平常所说的“事件驱动”,有什么区别和联系吗?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

17-经典PaaS的记忆:作业副本与水平扩展

你好,我是张磊。今天我和你分享的主题是:经典PaaS的记忆之作业副本与水平扩展。

在上一篇文章中,我为你详细介绍了Kubernetes项目中第一个重要的设计思想:控制器模式。

而在今天这篇文章中,我就来为你详细讲解一下,Kubernetes里第一个控制器模式的完整实现:Deployment。

Deployment看似简单,但实际上,它实现了Kubernetes项目中一个非常重要的功能:Pod的“水平扩展/收缩”(horizontal scaling out/in)。这个功能,是从PaaS时代开始,一个平台级项目就必须具备的编排能力。

举个例子,如果你更新了Deployment的Pod模板(比如,修改了容器的镜像),那么Deployment就需要遵循一种叫作“滚动更新”(rolling update)的方式,来升级现有的容器。

而这个能力的实现,依赖的是Kubernetes项目中的一个非常重要的概念(API对象):ReplicaSet。

ReplicaSet的结构非常简单,我们可以通过这个YAML文件查看一下:

apiVersion: apps/v1
kind: ReplicaSet
metadata:
  name: nginx-set
  labels:
    app: nginx
spec:
  replicas: 3
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.7.9

从这个YAML文件中,我们可以看到,一个ReplicaSet对象,其实就是由副本数目的定义和一个Pod模板组成的。不难发现,它的定义其实是Deployment的一个子集。

更重要的是,Deployment控制器实际操纵的,正是这样的ReplicaSet对象,而不是Pod对象。

还记不记得我在上一篇文章《编排其实很简单:谈谈“控制器”模型》中曾经提出过这样一个问题:对于一个Deployment所管理的Pod,它的ownerReference是谁?

所以,这个问题的答案就是:ReplicaSet。

明白了这个原理,我再来和你一起分析一个如下所示的Deployment:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
  labels:
    app: nginx
spec:
  replicas: 3
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.7.9
        ports:
        - containerPort: 80

可以看到,这就是一个我们常用的nginx-deployment,它定义的Pod副本个数是3(spec.replicas=3)。

那么,在具体的实现上,这个Deployment,与ReplicaSet,以及Pod的关系是怎样的呢?

我们可以用一张图把它描述出来:

通过这张图,我们就很清楚地看到,一个定义了replicas=3的Deployment,与它的ReplicaSet,以及Pod的关系,实际上是一种“层层控制”的关系。

其中,ReplicaSet负责通过“控制器模式”,保证系统中Pod的个数永远等于指定的个数(比如,3个)。这也正是Deployment只允许容器的restartPolicy=Always的主要原因:只有在容器能保证自己始终是Running状态的前提下,ReplicaSet调整Pod的个数才有意义。

而在此基础上,Deployment同样通过“控制器模式”,来操作ReplicaSet的个数和属性,进而实现“水平扩展/收缩”和“滚动更新”这两个编排动作。

其中,“水平扩展/收缩”非常容易实现,Deployment Controller只需要修改它所控制的ReplicaSet的Pod副本个数就可以了。

比如,把这个值从3改成4,那么Deployment所对应的ReplicaSet,就会根据修改后的值自动创建一个新的Pod。这就是“水平扩展”了;“水平收缩”则反之。

而用户想要执行这个操作的指令也非常简单,就是kubectl scale,比如:

$ kubectl scale deployment nginx-deployment --replicas=4
deployment.apps/nginx-deployment scaled

那么,“滚动更新”又是什么意思,是如何实现的呢?

接下来,我还以这个Deployment为例,来为你讲解“滚动更新”的过程。

首先,我们来创建这个nginx-deployment:

$ kubectl create -f nginx-deployment.yaml --record

注意,在这里,我额外加了一个–record参数。它的作用,是记录下你每次操作所执行的命令,以方便后面查看。

然后,我们来检查一下nginx-deployment创建后的状态信息:

$ kubectl get deployments
NAME               DESIRED   CURRENT   UP-TO-DATE   AVAILABLE   AGE
nginx-deployment   3         0         0            0           1s

在返回结果中,我们可以看到四个状态字段,它们的含义如下所示。

  1. DESIRED:用户期望的Pod副本个数(spec.replicas的值);

  2. CURRENT:当前处于Running状态的Pod的个数;

  3. UP-TO-DATE:当前处于最新版本的Pod的个数,所谓最新版本指的是Pod的Spec部分与Deployment里Pod模板里定义的完全一致;

  4. AVAILABLE:当前已经可用的Pod的个数,即:既是Running状态,又是最新版本,并且已经处于Ready(健康检查正确)状态的Pod的个数。

可以看到,只有这个AVAILABLE字段,描述的才是用户所期望的最终状态。

而Kubernetes项目还为我们提供了一条指令,让我们可以实时查看Deployment对象的状态变化。这个指令就是kubectl rollout status:

$ kubectl rollout status deployment/nginx-deployment
Waiting for rollout to finish: 2 out of 3 new replicas have been updated...
deployment.apps/nginx-deployment successfully rolled out

在这个返回结果中,“2 out of 3 new replicas have been updated”意味着已经有2个Pod进入了UP-TO-DATE状态。

继续等待一会儿,我们就能看到这个Deployment的3个Pod,就进入到了AVAILABLE状态:

NAME               DESIRED   CURRENT   UP-TO-DATE   AVAILABLE   AGE
nginx-deployment   3         3         3            3           20s

此时,你可以尝试查看一下这个Deployment所控制的ReplicaSet:

$ kubectl get rs
NAME                          DESIRED   CURRENT   READY   AGE
nginx-deployment-3167673210   3         3         3       20s

如上所示,在用户提交了一个Deployment对象后,Deployment Controller就会立即创建一个Pod副本个数为3的ReplicaSet。这个ReplicaSet的名字,则是由Deployment的名字和一个随机字符串共同组成。

这个随机字符串叫作pod-template-hash,在我们这个例子里就是:3167673210。ReplicaSet会把这个随机字符串加在它所控制的所有Pod的标签里,从而保证这些Pod不会与集群里的其他Pod混淆。

而ReplicaSet的DESIRED、CURRENT和READY字段的含义,和Deployment中是一致的。所以,相比之下,Deployment只是在ReplicaSet的基础上,添加了UP-TO-DATE这个跟版本有关的状态字段。

这个时候,如果我们修改了Deployment的Pod模板,“滚动更新”就会被自动触发。

修改Deployment有很多方法。比如,我可以直接使用kubectl edit指令编辑Etcd里的API对象。

$ kubectl edit deployment/nginx-deployment
...
    spec:
      containers:
      - name: nginx
        image: nginx:1.9.1 # 1.7.9 -> 1.9.1
        ports:
        - containerPort: 80
...
deployment.extensions/nginx-deployment edited

这个kubectl edit指令,会帮你直接打开nginx-deployment的API对象。然后,你就可以修改这里的Pod模板部分了。比如,在这里,我将nginx镜像的版本升级到了1.9.1。

备注:kubectl edit并不神秘,它不过是把API对象的内容下载到了本地文件,让你修改完成后再提交上去。

kubectl edit指令编辑完成后,保存退出,Kubernetes就会立刻触发“滚动更新”的过程。你还可以通过kubectl rollout status指令查看nginx-deployment的状态变化:

$ kubectl rollout status deployment/nginx-deployment
Waiting for rollout to finish: 2 out of 3 new replicas have been updated...
deployment.extensions/nginx-deployment successfully rolled out

这时,你可以通过查看Deployment的Events,看到这个“滚动更新”的流程:

$ kubectl describe deployment nginx-deployment
...
Events:
  Type    Reason             Age   From                   Message
  ----    ------             ----  ----                   -------
...
  Normal  ScalingReplicaSet  24s   deployment-controller  Scaled up replica set nginx-deployment-1764197365 to 1
  Normal  ScalingReplicaSet  22s   deployment-controller  Scaled down replica set nginx-deployment-3167673210 to 2
  Normal  ScalingReplicaSet  22s   deployment-controller  Scaled up replica set nginx-deployment-1764197365 to 2
  Normal  ScalingReplicaSet  19s   deployment-controller  Scaled down replica set nginx-deployment-3167673210 to 1
  Normal  ScalingReplicaSet  19s   deployment-controller  Scaled up replica set nginx-deployment-1764197365 to 3
  Normal  ScalingReplicaSet  14s   deployment-controller  Scaled down replica set nginx-deployment-3167673210 to 0

可以看到,首先,当你修改了Deployment里的Pod定义之后,Deployment Controller会使用这个修改后的Pod模板,创建一个新的ReplicaSet(hash=1764197365),这个新的ReplicaSet的初始Pod副本数是:0。

然后,在Age=24 s的位置,Deployment Controller开始将这个新的ReplicaSet所控制的Pod副本数从0个变成1个,即:“水平扩展”出一个副本。

紧接着,在Age=22 s的位置,Deployment Controller又将旧的ReplicaSet(hash=3167673210)所控制的旧Pod副本数减少一个,即:“水平收缩”成两个副本。

如此交替进行,新ReplicaSet管理的Pod副本数,从0个变成1个,再变成2个,最后变成3个。而旧的ReplicaSet管理的Pod副本数则从3个变成2个,再变成1个,最后变成0个。这样,就完成了这一组Pod的版本升级过程。

像这样,将一个集群中正在运行的多个Pod版本,交替地逐一升级的过程,就是“滚动更新”。

在这个“滚动更新”过程完成之后,你可以查看一下新、旧两个ReplicaSet的最终状态:

$ kubectl get rs
NAME                          DESIRED   CURRENT   READY   AGE
nginx-deployment-1764197365   3         3         3       6s
nginx-deployment-3167673210   0         0         0       30s

其中,旧ReplicaSet(hash=3167673210)已经被“水平收缩”成了0个副本。

这种“滚动更新”的好处是显而易见的。

比如,在升级刚开始的时候,集群里只有1个新版本的Pod。如果这时,新版本Pod有问题启动不起来,那么“滚动更新”就会停止,从而允许开发和运维人员介入。而在这个过程中,由于应用本身还有两个旧版本的Pod在线,所以服务并不会受到太大的影响。

当然,这也就要求你一定要使用Pod的Health Check机制检查应用的运行状态,而不是简单地依赖于容器的Running状态。要不然的话,虽然容器已经变成Running了,但服务很有可能尚未启动,“滚动更新”的效果也就达不到了。

而为了进一步保证服务的连续性,Deployment Controller还会确保,在任何时间窗口内,只有指定比例的Pod处于离线状态。同时,它也会确保,在任何时间窗口内,只有指定比例的新Pod被创建出来。这两个比例的值都是可以配置的,默认都是DESIRED值的25%。

所以,在上面这个Deployment的例子中,它有3个Pod副本,那么控制器在“滚动更新”的过程中永远都会确保至少有2个Pod处于可用状态,至多只有4个Pod同时存在于集群中。这个策略,是Deployment对象的一个字段,名叫RollingUpdateStrategy,如下所示:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
  labels:
    app: nginx
spec:
...
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 1

在上面这个RollingUpdateStrategy的配置中,maxSurge指定的是除了DESIRED数量之外,在一次“滚动”中,Deployment控制器还可以创建多少个新Pod;而maxUnavailable指的是,在一次“滚动”中,Deployment控制器可以删除多少个旧Pod。

同时,这两个配置还可以用前面我们介绍的百分比形式来表示,比如:maxUnavailable=50%,指的是我们最多可以一次删除“50%*DESIRED数量”个Pod。

结合以上讲述,现在我们可以扩展一下Deployment、ReplicaSet和Pod的关系图了。

如上所示,Deployment的控制器,实际上控制的是ReplicaSet的数目,以及每个ReplicaSet的属性。

而一个应用的版本,对应的正是一个ReplicaSet;这个版本应用的Pod数量,则由ReplicaSet通过它自己的控制器(ReplicaSet Controller)来保证。

通过这样的多个ReplicaSet对象,Kubernetes项目就实现了对多个“应用版本”的描述。

而明白了“应用版本和ReplicaSet一一对应”的设计思想之后,我就可以为你讲解一下Deployment对应用进行版本控制的具体原理了。

这一次,我会使用一个叫kubectl set image的指令,直接修改nginx-deployment所使用的镜像。这个命令的好处就是,你可以不用像kubectl edit那样需要打开编辑器。

不过这一次,我把这个镜像名字修改成为了一个错误的名字,比如:nginx:1.91。这样,这个Deployment就会出现一个升级失败的版本。

我们一起来实践一下:

$ kubectl set image deployment/nginx-deployment nginx=nginx:1.91
deployment.extensions/nginx-deployment image updated

由于这个nginx:1.91镜像在Docker Hub中并不存在,所以这个Deployment的“滚动更新”被触发后,会立刻报错并停止。

这时,我们来检查一下ReplicaSet的状态,如下所示:

$ kubectl get rs
NAME                          DESIRED   CURRENT   READY   AGE
nginx-deployment-1764197365   2         2         2       24s
nginx-deployment-3167673210   0         0         0       35s
nginx-deployment-2156724341   2         2         0       7s

通过这个返回结果,我们可以看到,新版本的ReplicaSet(hash=2156724341)的“水平扩展”已经停止。而且此时,它已经创建了两个Pod,但是它们都没有进入READY状态。这当然是因为这两个Pod都拉取不到有效的镜像。

与此同时,旧版本的ReplicaSet(hash=1764197365)的“水平收缩”,也自动停止了。此时,已经有一个旧Pod被删除,还剩下两个旧Pod。

那么问题来了, 我们如何让这个Deployment的3个Pod,都回滚到以前的旧版本呢?

我们只需要执行一条kubectl rollout undo命令,就能把整个Deployment回滚到上一个版本:

$ kubectl rollout undo deployment/nginx-deployment
deployment.extensions/nginx-deployment

很容易想到,在具体操作上,Deployment的控制器,其实就是让这个旧ReplicaSet(hash=1764197365)再次“扩展”成3个Pod,而让新的ReplicaSet(hash=2156724341)重新“收缩”到0个Pod。

更进一步地,如果我想回滚到更早之前的版本,要怎么办呢?

首先,我需要使用kubectl rollout history命令,查看每次Deployment变更对应的版本。而由于我们在创建这个Deployment的时候,指定了–record参数,所以我们创建这些版本时执行的kubectl命令,都会被记录下来。这个操作的输出如下所示:

$ kubectl rollout history deployment/nginx-deployment
deployments "nginx-deployment"
REVISION    CHANGE-CAUSE
1           kubectl create -f nginx-deployment.yaml --record
2           kubectl edit deployment/nginx-deployment
3           kubectl set image deployment/nginx-deployment nginx=nginx:1.91

可以看到,我们前面执行的创建和更新操作,分别对应了版本1和版本2,而那次失败的更新操作,则对应的是版本3。

当然,你还可以通过这个kubectl rollout history指令,看到每个版本对应的Deployment的API对象的细节,具体命令如下所示:

$ kubectl rollout history deployment/nginx-deployment --revision=2

然后,我们就可以在kubectl rollout undo命令行最后,加上要回滚到的指定版本的版本号,就可以回滚到指定版本了。这个指令的用法如下:

$ kubectl rollout undo deployment/nginx-deployment --to-revision=2
deployment.extensions/nginx-deployment

这样,Deployment Controller还会按照“滚动更新”的方式,完成对Deployment的降级操作。

不过,你可能已经想到了一个问题:我们对Deployment进行的每一次更新操作,都会生成一个新的ReplicaSet对象,是不是有些多余,甚至浪费资源呢?

没错。

所以,Kubernetes项目还提供了一个指令,使得我们对Deployment的多次更新操作,最后 只生成一个ReplicaSet。

具体的做法是,在更新Deployment前,你要先执行一条kubectl rollout pause指令。它的用法如下所示:

$ kubectl rollout pause deployment/nginx-deployment
deployment.extensions/nginx-deployment paused

这个kubectl rollout pause的作用,是让这个Deployment进入了一个“暂停”状态。

所以接下来,你就可以随意使用kubectl edit或者kubectl set image指令,修改这个Deployment的内容了。

由于此时Deployment正处于“暂停”状态,所以我们对Deployment的所有修改,都不会触发新的“滚动更新”,也不会创建新的ReplicaSet。

而等到我们对Deployment修改操作都完成之后,只需要再执行一条kubectl rollout resume指令,就可以把这个Deployment“恢复”回来,如下所示:

$ kubectl rollout resume deployment/nginx-deployment
deployment.extensions/nginx-deployment resumed

而在这个kubectl rollout resume指令执行之前,在kubectl rollout pause指令之后的这段时间里,我们对Deployment进行的所有修改,最后只会触发一次“滚动更新”。

当然,我们可以通过检查ReplicaSet状态的变化,来验证一下kubectl rollout pause和kubectl rollout resume指令的执行效果,如下所示:

$ kubectl get rs
NAME               DESIRED   CURRENT   READY     AGE
nginx-1764197365   0         0         0         2m
nginx-3196763511   3         3         3         28s

通过返回结果,我们可以看到,只有一个hash=3196763511的ReplicaSet被创建了出来。

不过,即使你像上面这样小心翼翼地控制了ReplicaSet的生成数量,随着应用版本的不断增加,Kubernetes中还是会为同一个Deployment保存很多很多不同的ReplicaSet。

那么,我们又该如何控制这些“历史”ReplicaSet的数量呢?

很简单,Deployment对象有一个字段,叫作spec.revisionHistoryLimit,就是Kubernetes为Deployment保留的“历史版本”个数。所以,如果把它设置为0,你就再也不能做回滚操作了。

总结

在今天这篇文章中,我为你详细讲解了Deployment这个Kubernetes项目中最基本的编排控制器的实现原理和使用方法。

通过这些讲解,你应该了解到:Deployment实际上是一个两层控制器。首先,它通过ReplicaSet的个数来描述应用的版本;然后,它再通过ReplicaSet的属性(比如replicas的值),来保证Pod的副本数量。

备注:Deployment控制ReplicaSet(版本),ReplicaSet控制Pod(副本数)。这个两层控制关系一定要牢记。

不过,相信你也能够感受到,Kubernetes项目对Deployment的设计,实际上是代替我们完成了对“应用”的抽象,使得我们可以使用这个Deployment对象来描述应用,使用kubectl rollout命令控制应用的版本。

可是,在实际使用场景中,应用发布的流程往往千差万别,也可能有很多的定制化需求。比如,我的应用可能有会话黏连(session sticky),这就意味着“滚动更新”的时候,哪个Pod能下线,是不能随便选择的。

这种场景,光靠Deployment自己就很难应对了。对于这种需求,我在专栏后续文章中重点介绍的“自定义控制器”,就可以帮我们实现一个功能更加强大的Deployment Controller。

当然,Kubernetes项目本身,也提供了另外一种抽象方式,帮我们应对其他一些用Deployment无法处理的应用编排场景。这个设计,就是对有状态应用的管理,也是我在下一篇文章中要重点讲解的内容。

思考题

你听说过金丝雀发布(Canary Deployment)和蓝绿发布(Blue-Green Deployment)吗?你能说出它们是什么意思吗?

实际上,有了Deployment的能力之后,你可以非常轻松地用它来实现金丝雀发布、蓝绿发布,以及A/B测试等很多应用发布模式。这些问题的答案都在这个GitHub库,建议你在课后实践一下。

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

18-深入理解StatefulSet(一):拓扑状态

你好,我是张磊。今天我和你分享的主题是:深入理解StatefulSet之拓扑状态。

在上一篇文章中,我在结尾处讨论到了Deployment实际上并不足以覆盖所有的应用编排问题。

造成这个问题的根本原因,在于Deployment对应用做了一个简单化假设。

它认为,一个应用的所有Pod,是完全一样的。所以,它们互相之间没有顺序,也无所谓运行在哪台宿主机上。需要的时候,Deployment就可以通过Pod模板创建新的Pod;不需要的时候,Deployment就可以“杀掉”任意一个Pod。

但是,在实际的场景中,并不是所有的应用都可以满足这样的要求。

尤其是分布式应用,它的多个实例之间,往往有依赖关系,比如:主从关系、主备关系。

还有就是数据存储类应用,它的多个实例,往往都会在本地磁盘上保存一份数据。而这些实例一旦被杀掉,即便重建出来,实例与数据之间的对应关系也已经丢失,从而导致应用失败。

所以,这种实例之间有不对等关系,以及实例对外部数据有依赖关系的应用,就被称为“有状态应用”(Stateful Application)。

容器技术诞生后,大家很快发现,它用来封装“无状态应用”(Stateless Application),尤其是Web服务,非常好用。但是,一旦你想要用容器运行“有状态应用”,其困难程度就会直线上升。而且,这个问题解决起来,单纯依靠容器技术本身已经无能为力,这也就导致了很长一段时间内,“有状态应用”几乎成了容器技术圈子的“忌讳”,大家一听到这个词,就纷纷摇头。

不过,Kubernetes项目还是成为了“第一个吃螃蟹的人”。

得益于“控制器模式”的设计思想,Kubernetes项目很早就在Deployment的基础上,扩展出了对“有状态应用”的初步支持。这个编排功能,就是:StatefulSet。

StatefulSet的设计其实非常容易理解。它把真实世界里的应用状态,抽象为了两种情况:

  1. 拓扑状态。这种情况意味着,应用的多个实例之间不是完全对等的关系。这些应用实例,必须按照某些顺序启动,比如应用的主节点A要先于从节点B启动。而如果你把A和B两个Pod删除掉,它们再次被创建出来时也必须严格按照这个顺序才行。并且,新创建出来的Pod,必须和原来Pod的网络标识一样,这样原先的访问者才能使用同样的方法,访问到这个新Pod。

  2. 存储状态。这种情况意味着,应用的多个实例分别绑定了不同的存储数据。对于这些应用实例来说,Pod A第一次读取到的数据,和隔了十分钟之后再次读取到的数据,应该是同一份,哪怕在此期间Pod A被重新创建过。这种情况最典型的例子,就是一个数据库应用的多个存储实例。

所以,StatefulSet的核心功能,就是通过某种方式记录这些状态,然后在Pod被重新创建时,能够为新Pod恢复这些状态。

在开始讲述StatefulSet的工作原理之前,我就必须先为你讲解一个Kubernetes项目中非常实用的概念:Headless Service。

我在和你一起讨论Kubernetes架构的时候就曾介绍过,Service是Kubernetes项目中用来将一组Pod暴露给外界访问的一种机制。比如,一个Deployment有3个Pod,那么我就可以定义一个Service。然后,用户只要能访问到这个Service,它就能访问到某个具体的Pod。

那么,这个Service又是如何被访问的呢?

第一种方式,是以Service的VIP(Virtual IP,即:虚拟IP)方式。比如:当我访问10.0.23.1这个Service的IP地址时,10.0.23.1其实就是一个VIP,它会把请求转发到该Service所代理的某一个Pod上。这里的具体原理,我会在后续的Service章节中进行详细介绍。

第二种方式,就是以Service的DNS方式。比如:这时候,只要我访问“my-svc.my-namespace.svc.cluster.local”这条DNS记录,就可以访问到名叫my-svc的Service所代理的某一个Pod。

而在第二种Service DNS的方式下,具体还可以分为两种处理方法:

第一种处理方法,是Normal Service。这种情况下,你访问“my-svc.my-namespace.svc.cluster.local”解析到的,正是my-svc这个Service的VIP,后面的流程就跟VIP方式一致了。

而第二种处理方法,正是Headless Service。这种情况下,你访问“my-svc.my-namespace.svc.cluster.local”解析到的,直接就是my-svc代理的某一个Pod的IP地址。可以看到,这里的区别在于,Headless Service不需要分配一个VIP,而是可以直接以DNS记录的方式解析出被代理Pod的IP地址。

那么,这样的设计又有什么作用呢?

想要回答这个问题,我们需要从Headless Service的定义方式看起。

下面是一个标准的Headless Service对应的YAML文件:

apiVersion: v1
kind: Service
metadata:
  name: nginx
  labels:
    app: nginx
spec:
  ports:
  - port: 80
    name: web
  clusterIP: None
  selector:
    app: nginx

可以看到,所谓的Headless Service,其实仍是一个标准Service的YAML文件。只不过,它的clusterIP字段的值是:None,即:这个Service,没有一个VIP作为“头”。这也就是Headless的含义。所以,这个Service被创建后并不会被分配一个VIP,而是会以DNS记录的方式暴露出它所代理的Pod。

而它所代理的Pod,依然是采用我在前面第12篇文章《牛刀小试:我的第一个容器化应用》中提到的Label Selector机制选择出来的,即:所有携带了app=nginx标签的Pod,都会被这个Service代理起来。

然后关键来了。

当你按照这样的方式创建了一个Headless Service之后,它所代理的所有Pod的IP地址,都会被绑定一个这样格式的DNS记录,如下所示:

<pod-name>.<svc-name>.<namespace>.svc.cluster.local

这个DNS记录,正是Kubernetes项目为Pod分配的唯一的“可解析身份”(Resolvable Identity)。

有了这个“可解析身份”,只要你知道了一个Pod的名字,以及它对应的Service的名字,你就可以非常确定地通过这条DNS记录访问到Pod的IP地址。

那么,StatefulSet又是如何使用这个DNS记录来维持Pod的拓扑状态的呢?

为了回答这个问题,现在我们就来编写一个StatefulSet的YAML文件,如下所示:

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: web
spec:
  serviceName: "nginx"
  replicas: 2
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.9.1
        ports:
        - containerPort: 80
          name: web

这个YAML文件,和我们在前面文章中用到的nginx-deployment的唯一区别,就是多了一个serviceName=nginx字段。

这个字段的作用,就是告诉StatefulSet控制器,在执行控制循环(Control Loop)的时候,请使用nginx这个Headless Service来保证Pod的“可解析身份”。

所以,当你通过kubectl create创建了上面这个Service和StatefulSet之后,就会看到如下两个对象:

$ kubectl create -f svc.yaml
$ kubectl get service nginx
NAME      TYPE         CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
nginx     ClusterIP    None         <none>        80/TCP    10s

$ kubectl create -f statefulset.yaml
$ kubectl get statefulset web
NAME      DESIRED   CURRENT   AGE
web       2         1         19s

这时候,如果你手比较快的话,还可以通过kubectl的-w参数,即:Watch功能,实时查看StatefulSet创建两个有状态实例的过程:

备注:如果手不够快的话,Pod很快就创建完了。不过,你依然可以通过这个StatefulSet的Events看到这些信息。

$ kubectl get pods -w -l app=nginx
NAME      READY     STATUS    RESTARTS   AGE
web-0     0/1       Pending   0          0s
web-0     0/1       Pending   0         0s
web-0     0/1       ContainerCreating   0         0s
web-0     1/1       Running   0         19s
web-1     0/1       Pending   0         0s
web-1     0/1       Pending   0         0s
web-1     0/1       ContainerCreating   0         0s
web-1     1/1       Running   0         20s

通过上面这个Pod的创建过程,我们不难看到,StatefulSet给它所管理的所有Pod的名字,进行了编号,编号规则是:<statefulset name>-<ordinal index>

而且这些编号都是从0开始累加,与StatefulSet的每个Pod实例一一对应,绝不重复。

更重要的是,这些Pod的创建,也是严格按照编号顺序进行的。比如,在web-0进入到Running状态、并且细分状态(Conditions)成为Ready之前,web-1会一直处于Pending状态。

备注:Ready状态再一次提醒了我们,为Pod设置livenessProbe和readinessProbe的重要性。

当这两个Pod都进入了Running状态之后,你就可以查看到它们各自唯一的“网络身份”了。

我们使用kubectl exec命令进入到容器中查看它们的hostname:

$ kubectl exec web-0 -- sh -c 'hostname'
web-0
$ kubectl exec web-1 -- sh -c 'hostname'
web-1

可以看到,这两个Pod的hostname与Pod名字是一致的,都被分配了对应的编号。接下来,我们再试着以DNS的方式,访问一下这个Headless Service:

$ kubectl run -i --tty --image busybox:1.28.4 dns-test --restart=Never --rm /bin/sh

通过这条命令,我们启动了一个一次性的Pod,因为--rm意味着Pod退出后就会被删除掉。然后,在这个Pod的容器里面,我们尝试用nslookup命令,解析一下Pod对应的Headless Service:

$ kubectl run -i --tty --image busybox:1.28.4 dns-test --restart=Never --rm /bin/sh
$ nslookup web-0.nginx
Server:    10.0.0.10
Address 1: 10.0.0.10 kube-dns.kube-system.svc.cluster.local

Name:      web-0.nginx
Address 1: 10.244.1.7

$ nslookup web-1.nginx
Server:    10.0.0.10
Address 1: 10.0.0.10 kube-dns.kube-system.svc.cluster.local

Name:      web-1.nginx
Address 1: 10.244.2.7

从nslookup命令的输出结果中,我们可以看到,在访问web-0.nginx的时候,最后解析到的,正是web-0这个Pod的IP地址;而当访问web-1.nginx的时候,解析到的则是web-1的IP地址。

这时候,如果你在另外一个Terminal里把这两个“有状态应用”的Pod删掉:

$ kubectl delete pod -l app=nginx
pod "web-0" deleted
pod "web-1" deleted

然后,再在当前Terminal里Watch一下这两个Pod的状态变化,就会发现一个有趣的现象:

$ kubectl get pod -w -l app=nginx
NAME      READY     STATUS              RESTARTS   AGE
web-0     0/1       ContainerCreating   0          0s
NAME      READY     STATUS    RESTARTS   AGE
web-0     1/1       Running   0          2s
web-1     0/1       Pending   0         0s
web-1     0/1       ContainerCreating   0         0s
web-1     1/1       Running   0         32s

可以看到,当我们把这两个Pod删除之后,Kubernetes会按照原先编号的顺序,创建出了两个新的Pod。并且,Kubernetes依然为它们分配了与原来相同的“网络身份”:web-0.nginx和web-1.nginx。

通过这种严格的对应规则,StatefulSet就保证了Pod网络标识的稳定性

比如,如果web-0是一个需要先启动的主节点,web-1是一个后启动的从节点,那么只要这个StatefulSet不被删除,你访问web-0.nginx时始终都会落在主节点上,访问web-1.nginx时,则始终都会落在从节点上,这个关系绝对不会发生任何变化。

所以,如果我们再用nslookup命令,查看一下这个新Pod对应的Headless Service的话:

$ kubectl run -i --tty --image busybox dns-test --restart=Never --rm /bin/sh
$ nslookup web-0.nginx
Server:    10.0.0.10
Address 1: 10.0.0.10 kube-dns.kube-system.svc.cluster.local

Name:      web-0.nginx
Address 1: 10.244.1.8

$ nslookup web-1.nginx
Server:    10.0.0.10
Address 1: 10.0.0.10 kube-dns.kube-system.svc.cluster.local

Name:      web-1.nginx
Address 1: 10.244.2.8

我们可以看到,在这个StatefulSet中,这两个新Pod的“网络标识”(比如:web-0.nginx和web-1.nginx),再次解析到了正确的IP地址(比如:web-0 Pod的IP地址10.244.1.8)。

通过这种方法,Kubernetes就成功地将Pod的拓扑状态(比如:哪个节点先启动,哪个节点后启动),按照Pod的“名字+编号”的方式固定了下来。此外,Kubernetes还为每一个Pod提供了一个固定并且唯一的访问入口,即:这个Pod对应的DNS记录。

这些状态,在StatefulSet的整个生命周期里都会保持不变,绝不会因为对应Pod的删除或者重新创建而失效。

不过,相信你也已经注意到了,尽管web-0.nginx这条记录本身不会变,但它解析到的Pod的IP地址,并不是固定的。这就意味着,对于“有状态应用”实例的访问,你必须使用DNS记录或者hostname的方式,而绝不应该直接访问这些Pod的IP地址。

总结

在今天这篇文章中,我首先和你分享了StatefulSet的基本概念,解释了什么是应用的“状态”。

紧接着 ,我为你分析了StatefulSet如何保证应用实例之间“拓扑状态”的稳定性。

如果用一句话来总结的话,你可以这么理解这个过程:

StatefulSet这个控制器的主要作用之一,就是使用Pod模板创建Pod的时候,对它们进行编号,并且按照编号顺序逐一完成创建工作。而当StatefulSet的“控制循环”发现Pod的“实际状态”与“期望状态”不一致,需要新建或者删除Pod进行“调谐”的时候,它会严格按照这些Pod编号的顺序,逐一完成这些操作。

所以,StatefulSet其实可以认为是对Deployment的改良。

与此同时,通过Headless Service的方式,StatefulSet为每个Pod创建了一个固定并且稳定的DNS记录,来作为它的访问入口。

实际上,在部署“有状态应用”的时候,应用的每个实例拥有唯一并且稳定的“网络标识”,是一个非常重要的假设。

在下一篇文章中,我将会继续为你剖析StatefulSet如何处理存储状态。

思考题

你曾经运维过哪些有拓扑状态的应用呢(比如:主从、主主、主备、一主多从等结构)?你觉得这些应用实例之间的拓扑关系,能否借助这种为Pod实例编号的方式表达出来呢?如果不能,你觉得Kubernetes还应该为你提供哪些支持来管理这个拓扑状态呢?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

19-深入理解StatefulSet(二):存储状态

你好,我是张磊。今天我和你分享的主题是:深入理解StatefulSet之存储状态。

在上一篇文章中,我和你分享了StatefulSet如何保证应用实例的拓扑状态,在Pod删除和再创建的过程中保持稳定。

而在今天这篇文章中,我将继续为你解读StatefulSet对存储状态的管理机制。这个机制,主要使用的是一个叫作Persistent Volume Claim的功能。

在前面介绍Pod的时候,我曾提到过,要在一个Pod里声明Volume,只要在Pod里加上spec.volumes字段即可。然后,你就可以在这个字段里定义一个具体类型的Volume了,比如:hostPath。

可是,你有没有想过这样一个场景:如果你并不知道有哪些Volume类型可以用,要怎么办呢

更具体地说,作为一个应用开发者,我可能对持久化存储项目(比如Ceph、GlusterFS等)一窍不通,也不知道公司的Kubernetes集群里到底是怎么搭建出来的,我也自然不会编写它们对应的Volume定义文件。

所谓“术业有专攻”,这些关于Volume的管理和远程持久化存储的知识,不仅超越了开发者的知识储备,还会有暴露公司基础设施秘密的风险。

比如,下面这个例子,就是一个声明了Ceph RBD类型Volume的Pod:

apiVersion: v1
kind: Pod
metadata:
  name: rbd
spec:
  containers:
    - image: kubernetes/pause
      name: rbd-rw
      volumeMounts:
      - name: rbdpd
        mountPath: /mnt/rbd
  volumes:
    - name: rbdpd
      rbd:
        monitors:
        - '10.16.154.78:6789'
        - '10.16.154.82:6789'
        - '10.16.154.83:6789'
        pool: kube
        image: foo
        fsType: ext4
        readOnly: true
        user: admin
        keyring: /etc/ceph/keyring
        imageformat: "2"
        imagefeatures: "layering"

其一,如果不懂得Ceph RBD的使用方法,那么这个Pod里Volumes字段,你十有八九也完全看不懂。其二,这个Ceph RBD对应的存储服务器的地址、用户名、授权文件的位置,也都被轻易地暴露给了全公司的所有开发人员,这是一个典型的信息被“过度暴露”的例子。

这也是为什么,在后来的演化中,Kubernetes项目引入了一组叫作Persistent Volume Claim(PVC)和Persistent Volume(PV)的API对象,大大降低了用户声明和使用持久化Volume的门槛。

举个例子,有了PVC之后,一个开发人员想要使用一个Volume,只需要简单的两步即可。

第一步:定义一个PVC,声明想要的Volume的属性:

kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: pv-claim
spec:
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 1Gi

可以看到,在这个PVC对象里,不需要任何关于Volume细节的字段,只有描述性的属性和定义。比如,storage: 1Gi,表示我想要的Volume大小至少是1 GiB;accessModes: ReadWriteOnce,表示这个Volume的挂载方式是可读写,并且只能被挂载在一个节点上而非被多个节点共享。

备注:关于哪种类型的Volume支持哪种类型的AccessMode,你可以查看Kubernetes项目官方文档中的详细列表

第二步:在应用的Pod中,声明使用这个PVC:

apiVersion: v1
kind: Pod
metadata:
  name: pv-pod
spec:
  containers:
    - name: pv-container
      image: nginx
      ports:
        - containerPort: 80
          name: "http-server"
      volumeMounts:
        - mountPath: "/usr/share/nginx/html"
          name: pv-storage
  volumes:
    - name: pv-storage
      persistentVolumeClaim:
        claimName: pv-claim

可以看到,在这个Pod的Volumes定义中,我们只需要声明它的类型是persistentVolumeClaim,然后指定PVC的名字,而完全不必关心Volume本身的定义。

这时候,只要我们创建这个PVC对象,Kubernetes就会自动为它绑定一个符合条件的Volume。可是,这些符合条件的Volume又是从哪里来的呢?

答案是,它们来自于由运维人员维护的PV(Persistent Volume)对象。接下来,我们一起看一个常见的PV对象的YAML文件:

kind: PersistentVolume
apiVersion: v1
metadata:
  name: pv-volume
  labels:
    type: local
spec:
  capacity:
    storage: 10Gi
  accessModes:
    - ReadWriteOnce
  rbd:
    monitors:
    # 使用 kubectl get pods -n rook-ceph 查看 rook-ceph-mon- 开头的 POD IP 即可得下面的列表
    - '10.16.154.78:6789'
    - '10.16.154.82:6789'
    - '10.16.154.83:6789'
    pool: kube
    image: foo
    fsType: ext4
    readOnly: true
    user: admin
    keyring: /etc/ceph/keyring

可以看到,这个PV对象的spec.rbd字段,正是我们前面介绍过的Ceph RBD Volume的详细定义。而且,它还声明了这个PV的容量是10 GiB。这样,Kubernetes就会为我们刚刚创建的PVC对象绑定这个PV。

所以,Kubernetes中PVC和PV的设计,实际上类似于“接口”和“实现”的思想。开发者只要知道并会使用“接口”,即:PVC;而运维人员则负责给“接口”绑定具体的实现,即:PV。

这种解耦,就避免了因为向开发者暴露过多的存储系统细节而带来的隐患。此外,这种职责的分离,往往也意味着出现事故时可以更容易定位问题和明确责任,从而避免“扯皮”现象的出现。

而PVC、PV的设计,也使得StatefulSet对存储状态的管理成为了可能。我们还是以上一篇文章中用到的StatefulSet为例(你也可以借此再回顾一下《深入理解StatefulSet(一):拓扑状态》中的相关内容):

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: web
spec:
  serviceName: "nginx"
  replicas: 2
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.9.1
        ports:
        - containerPort: 80
          name: web
        volumeMounts:
        - name: www
          mountPath: /usr/share/nginx/html
  volumeClaimTemplates:
  - metadata:
      name: www
    spec:
      accessModes:
      - ReadWriteOnce
      resources:
        requests:
          storage: 1Gi

这次,我们为这个StatefulSet额外添加了一个volumeClaimTemplates字段。从名字就可以看出来,它跟Deployment里Pod模板(PodTemplate)的作用类似。也就是说,凡是被这个StatefulSet管理的Pod,都会声明一个对应的PVC;而这个PVC的定义,就来自于volumeClaimTemplates这个模板字段。更重要的是,这个PVC的名字,会被分配一个与这个Pod完全一致的编号。

这个自动创建的PVC,与PV绑定成功后,就会进入Bound状态,这就意味着这个Pod可以挂载并使用这个PV了。

如果你还是不太理解PVC的话,可以先记住这样一个结论:PVC其实就是一种特殊的Volume。只不过一个PVC具体是什么类型的Volume,要在跟某个PV绑定之后才知道。关于PV、PVC更详细的知识,我会在容器存储部分做进一步解读。

当然,PVC与PV的绑定得以实现的前提是,运维人员已经在系统里创建好了符合条件的PV(比如,我们在前面用到的pv-volume);或者,你的Kubernetes集群运行在公有云上,这样Kubernetes就会通过Dynamic Provisioning的方式,自动为你创建与PVC匹配的PV。

所以,我们在使用kubectl create创建了StatefulSet之后,就会看到Kubernetes集群里出现了两个PVC:

$ kubectl create -f statefulset.yaml
$ kubectl get pvc -l app=nginx
NAME        STATUS    VOLUME                                     CAPACITY   ACCESSMODES   AGE
www-web-0   Bound     pvc-15c268c7-b507-11e6-932f-42010a800002   1Gi        RWO           48s
www-web-1   Bound     pvc-15c79307-b507-11e6-932f-42010a800002   1Gi        RWO           48s

可以看到,这些PVC,都以“<PVC名字>-<StatefulSet名字>-<编号>”的方式命名,并且处于Bound状态。

我们前面已经讲到过,这个StatefulSet创建出来的所有Pod,都会声明使用编号的PVC。比如,在名叫web-0的Pod的volumes字段,它会声明使用名叫www-web-0的PVC,从而挂载到这个PVC所绑定的PV。

所以,我们就可以使用如下所示的指令,在Pod的Volume目录里写入一个文件,来验证一下上述Volume的分配情况:

$ for i in 0 1; do kubectl exec web-$i -- sh -c 'echo hello $(hostname) > /usr/share/nginx/html/index.html'; done

如上所示,通过kubectl exec指令,我们在每个Pod的Volume目录里,写入了一个index.html文件。这个文件的内容,正是Pod的hostname。比如,我们在web-0的index.html里写入的内容就是"hello web-0"。

此时,如果你在这个Pod容器里访问“http://localhost”,你实际访问到的就是Pod里Nginx服务器进程,而它会为你返回/usr/share/nginx/html/index.html里的内容。这个操作的执行方法如下所示:

$ for i in 0 1; do kubectl exec -it web-$i -- curl localhost; done
hello web-0
hello web-1

现在,关键来了。

如果你使用kubectl delete命令删除这两个Pod,这些Volume里的文件会不会丢失呢?

$ kubectl delete pod -l app=nginx
pod "web-0" deleted
pod "web-1" deleted

可以看到,正如我们前面介绍过的,在被删除之后,这两个Pod会被按照编号的顺序被重新创建出来。而这时候,如果你在新创建的容器里通过访问“http://localhost”的方式去访问web-0里的Nginx服务:

# 在被重新创建出来的Pod容器里访问http://localhost
$ kubectl exec -it web-0 -- curl localhost
hello web-0

就会发现,这个请求依然会返回:hello web-0。也就是说,原先与名叫web-0的Pod绑定的PV,在这个Pod被重新创建之后,依然同新的名叫web-0的Pod绑定在了一起。对于Pod web-1来说,也是完全一样的情况。

这是怎么做到的呢?

其实,我和你分析一下StatefulSet控制器恢复这个Pod的过程,你就可以很容易理解了。

首先,当你把一个Pod,比如web-0,删除之后,这个Pod对应的PVC和PV,并不会被删除,而这个Volume里已经写入的数据,也依然会保存在远程存储服务里(比如,我们在这个例子里用到的Ceph服务器)。

此时,StatefulSet控制器发现,一个名叫web-0的Pod消失了。所以,控制器就会重新创建一个新的、名字还是叫作web-0的Pod来,“纠正”这个不一致的情况。

需要注意的是,在这个新的Pod对象的定义里,它声明使用的PVC的名字,还是叫作:www-web-0。这个PVC的定义,还是来自于PVC模板(volumeClaimTemplates),这是StatefulSet创建Pod的标准流程。

所以,在这个新的web-0 Pod被创建出来之后,Kubernetes为它查找名叫www-web-0的PVC时,就会直接找到旧Pod遗留下来的同名的PVC,进而找到跟这个PVC绑定在一起的PV。

这样,新的Pod就可以挂载到旧Pod对应的那个Volume,并且获取到保存在Volume里的数据。

通过这种方式,Kubernetes的StatefulSet就实现了对应用存储状态的管理。

看到这里,你是不是已经大致理解了StatefulSet的工作原理呢?现在,我再为你详细梳理一下吧。

首先,StatefulSet的控制器直接管理的是Pod。这是因为,StatefulSet里的不同Pod实例,不再像ReplicaSet中那样都是完全一样的,而是有了细微区别的。比如,每个Pod的hostname、名字等都是不同的、携带了编号的。而StatefulSet区分这些实例的方式,就是通过在Pod的名字里加上事先约定好的编号。

其次,Kubernetes通过Headless Service,为这些有编号的Pod,在DNS服务器中生成带有同样编号的DNS记录。只要StatefulSet能够保证这些Pod名字里的编号不变,那么Service里类似于web-0.nginx.default.svc.cluster.local这样的DNS记录也就不会变,而这条记录解析出来的Pod的IP地址,则会随着后端Pod的删除和再创建而自动更新。这当然是Service机制本身的能力,不需要StatefulSet操心。

最后,StatefulSet还为每一个Pod分配并创建一个同样编号的PVC。这样,Kubernetes就可以通过Persistent Volume机制为这个PVC绑定上对应的PV,从而保证了每一个Pod都拥有一个独立的Volume。

在这种情况下,即使Pod被删除,它所对应的PVC和PV依然会保留下来。所以当这个Pod被重新创建出来之后,Kubernetes会为它找到同样编号的PVC,挂载这个PVC对应的Volume,从而获取到以前保存在Volume里的数据。

这么一看,原本非常复杂的StatefulSet,是不是也很容易理解了呢?

总结

在今天这篇文章中,我为你详细介绍了StatefulSet处理存储状态的方法。然后,以此为基础,我为你梳理了StatefulSet控制器的工作原理。

从这些讲述中,我们不难看出StatefulSet的设计思想:StatefulSet其实就是一种特殊的Deployment,而其独特之处在于,它的每个Pod都被编号了。而且,这个编号会体现在Pod的名字和hostname等标识信息上,这不仅代表了Pod的创建顺序,也是Pod的重要网络标识(即:在整个集群里唯一的、可被访问的身份)。

有了这个编号后,StatefulSet就使用Kubernetes里的两个标准功能:Headless Service和PV/PVC,实现了对Pod的拓扑状态和存储状态的维护。

实际上,在下一篇文章的“有状态应用”实践环节,以及后续的讲解中,你就会逐渐意识到,StatefulSet可以说是Kubernetes中作业编排的“集大成者”。

因为,几乎每一种Kubernetes的编排功能,都可以在编写StatefulSet的YAML文件时被用到。

思考题

在实际场景中,有一些分布式应用的集群是这么工作的:当一个新节点加入到集群时,或者老节点被迁移后重建时,这个节点可以从主节点或者其他从节点那里同步到自己所需要的数据。

在这种情况下,你认为是否还有必要将这个节点Pod与它的PV进行一对一绑定呢?(提示:这个问题的答案根据不同的项目是不同的。关键在于,重建后的节点进行数据恢复和同步的时候,是不是一定需要原先它写在本地磁盘里的数据)

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

20-深入理解StatefulSet(三):有状态应用实践

你好,我是张磊。今天我和你分享的主题是:深入理解StatefulSet之有状态应用实践。

在前面的两篇文章中,我详细讲解了StatefulSet的工作原理,以及处理拓扑状态和存储状态的方法。而在今天这篇文章中,我将通过一个实际的例子,再次为你深入解读一下部署一个StatefulSet的完整流程。

今天我选择的实例是部署一个MySQL集群,这也是Kubernetes官方文档里的一个经典案例。但是,很多工程师都曾向我吐槽说这个例子“完全看不懂”。

其实,这样的吐槽也可以理解:相比于Etcd、Cassandra等“原生”就考虑了分布式需求的项目,MySQL以及很多其他的数据库项目,在分布式集群的搭建上并不友好,甚至有点“原始”。

所以,这次我就直接选择了这个具有挑战性的例子,和你分享如何使用StatefulSet将它的集群搭建过程“容器化”。

备注:在开始实践之前,请确保我们之前一起部署的那个Kubernetes集群还是可用的,并且网络插件和存储插件都能正常运行。具体的做法,请参考第11篇文章《从0到1:搭建一个完整的Kubernetes集群》的内容。

首先,用自然语言来描述一下我们想要部署的“有状态应用”。

  1. 是一个“主从复制”(Maser-Slave Replication)的MySQL集群;

  2. 有1个主节点(Master);

  3. 有多个从节点(Slave);

  4. 从节点需要能水平扩展;

  5. 所有的写操作,只能在主节点上执行;

  6. 读操作可以在所有节点上执行。

这就是一个非常典型的主从模式的MySQL集群了。我们可以把上面描述的“有状态应用”的需求,通过一张图来表示。


在常规环境里,部署这样一个主从模式的MySQL集群的主要难点在于:如何让从节点能够拥有主节点的数据,即:如何配置主(Master)从(Slave)节点的复制与同步。

所以,在安装好MySQL的Master节点之后,你需要做的第一步工作,就是通过XtraBackup将Master节点的数据备份到指定目录。

备注:XtraBackup是业界主要使用的开源MySQL备份和恢复工具。

这一步会自动在目标目录里生成一个备份信息文件,名叫:xtrabackup_binlog_info。这个文件一般会包含如下两个信息:

$ cat xtrabackup_binlog_info
TheMaster-bin.000001     481

这两个信息会在接下来配置Slave节点的时候用到。

第二步:配置Slave节点。Slave节点在第一次启动前,需要先把Master节点的备份数据,连同备份信息文件,一起拷贝到自己的数据目录(/var/lib/mysql)下。然后,我们执行这样一句SQL:

TheSlave|mysql> CHANGE MASTER TO
                MASTER_HOST='$masterip',
                MASTER_USER='xxx',
                MASTER_PASSWORD='xxx',
                MASTER_LOG_FILE='TheMaster-bin.000001',
                MASTER_LOG_POS=481;

其中,MASTER_LOG_FILE和MASTER_LOG_POS,就是该备份对应的二进制日志(Binary Log)文件的名称和开始的位置(偏移量),也正是xtrabackup_binlog_info文件里的那两部分内容(即:TheMaster-bin.000001和481)。

第三步,启动Slave节点。在这一步,我们需要执行这样一句SQL:

TheSlave|mysql> START SLAVE;

这样,Slave节点就启动了。它会使用备份信息文件中的二进制日志文件和偏移量,与主节点进行数据同步。

第四步,在这个集群中添加更多的Slave节点

需要注意的是,新添加的Slave节点的备份数据,来自于已经存在的Slave节点。

所以,在这一步,我们需要将Slave节点的数据备份在指定目录。而这个备份操作会自动生成另一种备份信息文件,名叫:xtrabackup_slave_info。同样地,这个文件也包含了MASTER_LOG_FILE和MASTER_LOG_POS两个字段。

然后,我们就可以执行跟前面一样的“CHANGE MASTER TO”和“START SLAVE” 指令,来初始化并启动这个新的Slave节点了。

通过上面的叙述,我们不难看到,将部署MySQL集群的流程迁移到Kubernetes项目上,需要能够“容器化”地解决下面的“三座大山”:

  1. Master节点和Slave节点需要有不同的配置文件(即:不同的my.cnf);

  2. Master节点和Slave节点需要能够传输备份信息文件;

  3. 在Slave节点第一次启动之前,需要执行一些初始化SQL操作;

而由于MySQL本身同时拥有拓扑状态(主从节点的区别)和存储状态(MySQL保存在本地的数据),我们自然要通过StatefulSet来解决这“三座大山”的问题。

其中,“第一座大山:Master节点和Slave节点需要有不同的配置文件”,很容易处理:我们只需要给主从节点分别准备两份不同的MySQL配置文件,然后根据Pod的序号(Index)挂载进去即可。

正如我在前面文章中介绍过的,这样的配置文件信息,应该保存在ConfigMap里供Pod使用。它的定义如下所示:

apiVersion: v1
kind: ConfigMap
metadata:
  name: mysql
  labels:
    app: mysql
data:
  master.cnf: |
    # 主节点MySQL的配置文件
    [mysqld]
    log-bin
  slave.cnf: |
    # 从节点MySQL的配置文件
    [mysqld]
    super-read-only

在这里,我们定义了master.cnf和slave.cnf两个MySQL的配置文件。

  • master.cnf开启了log-bin,即:使用二进制日志文件的方式进行主从复制,这是一个标准的设置。
  • slave.cnf的开启了super-read-only,代表的是从节点会拒绝除了主节点的数据同步操作之外的所有写操作,即:它对用户是只读的。

而上述ConfigMap定义里的data部分,是Key-Value格式的。比如,master.cnf就是这份配置数据的Key,而“|”后面的内容,就是这份配置数据的Value。这份数据将来挂载进Master节点对应的Pod后,就会在Volume目录里生成一个叫作master.cnf的文件。

备注:如果你对ConfigMap的用法感到陌生的话,可以稍微复习一下第15篇文章《深入解析Pod对象(二):使用进阶》中,我讲解Secret对象部分的内容。因为,ConfigMap跟Secret,无论是使用方法还是实现原理,几乎都是一样的。

接下来,我们需要创建两个Service来供StatefulSet以及用户使用。这两个Service的定义如下所示:

apiVersion: v1
kind: Service
metadata:
  name: mysql
  labels:
    app: mysql
spec:
  ports:
  - name: mysql
    port: 3306
  clusterIP: None
  selector:
    app: mysql
---
apiVersion: v1
kind: Service
metadata:
  name: mysql-read
  labels:
    app: mysql
spec:
  ports:
  - name: mysql
    port: 3306
  selector:
    app: mysql

可以看到,这两个Service都代理了所有携带app=mysql标签的Pod,也就是所有的MySQL Pod。端口映射都是用Service的3306端口对应Pod的3306端口。

不同的是,第一个名叫“mysql”的Service是一个Headless Service(即:clusterIP= None)。所以它的作用,是通过为Pod分配DNS记录来固定它的拓扑状态,比如“mysql-0.mysql”和“mysql-1.mysql”这样的DNS名字。其中,编号为0的节点就是我们的主节点。

而第二个名叫“mysql-read”的Service,则是一个常规的Service。

并且我们规定,所有用户的读请求,都必须访问第二个Service被自动分配的DNS记录,即:“mysql-read”(当然,也可以访问这个Service的VIP)。这样,读请求就可以被转发到任意一个MySQL的主节点或者从节点上。

备注:Kubernetes中的所有Service、Pod对象,都会被自动分配同名的DNS记录。具体细节,我会在后面Service部分做重点讲解。

而所有用户的写请求,则必须直接以DNS记录的方式访问到MySQL的主节点,也就是:“mysql-0.mysql“这条DNS记录。

接下来,我们再一起解决“第二座大山:Master节点和Slave节点需要能够传输备份文件”的问题。

翻越这座大山的思路,我比较推荐的做法是:先搭建框架,再完善细节。其中,Pod部分如何定义,是完善细节时的重点。

所以首先,我们先为StatefulSet对象规划一个大致的框架,如下图所示:

在这一步,我们可以先为StatefulSet定义一些通用的字段。

比如:selector表示,这个StatefulSet要管理的Pod必须携带app=mysql标签;它声明要使用的Headless Service的名字是:mysql。

这个StatefulSet的replicas值是3,表示它定义的MySQL集群有三个节点:一个Master节点,两个Slave节点。

可以看到,StatefulSet管理的“有状态应用”的多个实例,也都是通过同一份Pod模板创建出来的,使用的是同一个Docker镜像。这也就意味着:如果你的应用要求不同节点的镜像不一样,那就不能再使用StatefulSet了。对于这种情况,应该考虑我后面会讲解到的Operator。

除了这些基本的字段外,作为一个有存储状态的MySQL集群,StatefulSet还需要管理存储状态。所以,我们需要通过volumeClaimTemplate(PVC模板)来为每个Pod定义PVC。比如,这个PVC模板的resources.requests.strorage指定了存储的大小为10 GiB;ReadWriteOnce指定了该存储的属性为可读写,并且一个PV只允许挂载在一个宿主机上。将来,这个PV对应的的Volume就会充当MySQL Pod的存储数据目录。

然后,我们来重点设计一下这个StatefulSet的Pod模板,也就是template字段。

由于StatefulSet管理的Pod都来自于同一个镜像,这就要求我们在编写Pod时,一定要保持清醒,用“人格分裂”的方式进行思考:

  1. 如果这个Pod是Master节点,我们要怎么做;

  2. 如果这个Pod是Slave节点,我们又要怎么做。

想清楚这两个问题,我们就可以按照Pod的启动过程来一步步定义它们了。

第一步:从ConfigMap中,获取MySQL的Pod对应的配置文件。

为此,我们需要进行一个初始化操作,根据节点的角色是Master还是Slave节点,为Pod分配对应的配置文件。此外,MySQL还要求集群里的每个节点都有一个唯一的ID文件,名叫server-id.cnf。

而根据我们已经掌握的Pod知识,这些初始化操作显然适合通过InitContainer来完成。所以,我们首先定义了一个InitContainer,如下所示:

      ...
      # template.spec
      initContainers:
      - name: init-mysql
        image: mysql:5.7
        command:
        - bash
        - "-c"
        - |
          set -ex
          # 从Pod的序号,生成server-id
          [[ `hostname` =~ -([0-9]+)$ ]] || exit 1
          ordinal=${BASH_REMATCH[1]}
          echo [mysqld] > /mnt/conf.d/server-id.cnf
          # 由于server-id=0有特殊含义,我们给ID加一个100来避开它
          echo server-id=$((100 + $ordinal)) >> /mnt/conf.d/server-id.cnf
          # 如果Pod序号是0,说明它是Master节点,从ConfigMap里把Master的配置文件拷贝到/mnt/conf.d/目录;
          # 否则,拷贝Slave的配置文件
          if [[ $ordinal -eq 0 ]]; then
            cp /mnt/config-map/master.cnf /mnt/conf.d/
          else
            cp /mnt/config-map/slave.cnf /mnt/conf.d/
          fi
        volumeMounts:
        - name: conf
          mountPath: /mnt/conf.d
        - name: config-map
          mountPath: /mnt/config-map

在这个名叫init-mysql的InitContainer的配置中,它从Pod的hostname里,读取到了Pod的序号,以此作为MySQL节点的server-id。

然后,init-mysql通过这个序号,判断当前Pod到底是Master节点(即:序号为0)还是Slave节点(即:序号不为0),从而把对应的配置文件从/mnt/config-map目录拷贝到/mnt/conf.d/目录下。

其中,文件拷贝的源目录/mnt/config-map,正是ConfigMap在这个Pod的Volume,如下所示:

      ...
      # template.spec
      volumes:
      - name: conf
        emptyDir: {}
      - name: config-map
        configMap:
          name: mysql

通过这个定义,init-mysql在声明了挂载config-map这个Volume之后,ConfigMap里保存的内容,就会以文件的方式出现在它的/mnt/config-map目录当中。

而文件拷贝的目标目录,即容器里的/mnt/conf.d/目录,对应的则是一个名叫conf的、emptyDir类型的Volume。基于Pod Volume共享的原理,当InitContainer复制完配置文件退出后,后面启动的MySQL容器只需要直接声明挂载这个名叫conf的Volume,它所需要的.cnf配置文件已经出现在里面了。这跟我们之前介绍的Tomcat和WAR包的处理方法是完全一样的。

第二步:在Slave Pod启动前,从Master或者其他Slave Pod里拷贝数据库数据到自己的目录下。

为了实现这个操作,我们就需要再定义第二个InitContainer,如下所示:

      ...
      # template.spec.initContainers
      - name: clone-mysql
        image: gcr.io/google-samples/xtrabackup:1.0
        command:
        - bash
        - "-c"
        - |
          set -ex
          # 拷贝操作只需要在第一次启动时进行,所以如果数据已经存在,跳过
          [[ -d /var/lib/mysql/mysql ]] && exit 0
          # Master节点(序号为0)不需要做这个操作
          [[ `hostname` =~ -([0-9]+)$ ]] || exit 1
          ordinal=${BASH_REMATCH[1]}
          [[ $ordinal -eq 0 ]] && exit 0
          # 使用ncat指令,远程地从前一个节点拷贝数据到本地
          ncat --recv-only mysql-$(($ordinal-1)).mysql 3307 | xbstream -x -C /var/lib/mysql
          # 执行--prepare,这样拷贝来的数据就可以用作恢复了
          xtrabackup --prepare --target-dir=/var/lib/mysql
        volumeMounts:
        - name: data
          mountPath: /var/lib/mysql
          subPath: mysql
        - name: conf
          mountPath: /etc/mysql/conf.d

在这个名叫clone-mysql的InitContainer里,我们使用的是xtrabackup镜像(它里面安装了xtrabackup工具)。

而在它的启动命令里,我们首先做了一个判断。即:当初始化所需的数据(/var/lib/mysql/mysql 目录)已经存在,或者当前Pod是Master节点的时候,不需要做拷贝操作。

接下来,clone-mysql会使用Linux自带的ncat指令,向DNS记录为“mysql-<当前序号减一>.mysql”的Pod,也就是当前Pod的前一个Pod,发起数据传输请求,并且直接用xbstream指令将收到的备份数据保存在/var/lib/mysql目录下。

备注:3307是一个特殊端口,运行着一个专门负责备份MySQL数据的辅助进程。我们后面马上会讲到它。

当然,这一步你可以随意选择用自己喜欢的方法来传输数据。比如,用scp或者rsync,都没问题。

你可能已经注意到,这个容器里的/var/lib/mysql目录,实际上正是一个名为data的PVC,即:我们在前面声明的持久化存储。

这就可以保证,哪怕宿主机宕机了,我们数据库的数据也不会丢失。更重要的是,由于Pod Volume是被Pod里的容器共享的,所以后面启动的MySQL容器,就可以把这个Volume挂载到自己的/var/lib/mysql目录下,直接使用里面的备份数据进行恢复操作。

不过,clone-mysql容器还要对/var/lib/mysql目录,执行一句xtrabackup --prepare操作,目的是让拷贝来的数据进入一致性状态,这样,这些数据才能被用作数据恢复。

至此,我们就通过InitContainer完成了对“主、从节点间备份文件传输”操作的处理过程,也就是翻越了“第二座大山”。

接下来,我们可以开始定义MySQL容器,启动MySQL服务了。由于StatefulSet里的所有Pod都来自用同一个Pod模板,所以我们还要“人格分裂”地去思考:这个MySQL容器的启动命令,在Master和Slave两种情况下有什么不同。

有了Docker镜像,在Pod里声明一个Master角色的MySQL容器并不是什么困难的事情:直接执行MySQL启动命令即可。

但是,如果这个Pod是一个第一次启动的Slave节点,在执行MySQL启动命令之前,它就需要使用前面InitContainer拷贝来的备份数据进行初始化。

可是,别忘了,容器是一个单进程模型。

所以,一个Slave角色的MySQL容器启动之前,谁能负责给它执行初始化的SQL语句呢?

这就是我们需要解决的“第三座大山”的问题,即:如何在Slave节点的MySQL容器第一次启动之前,执行初始化SQL。

你可能已经想到了,我们可以为这个MySQL容器额外定义一个sidecar容器,来完成这个操作,它的定义如下所示:

      ...
      # template.spec.containers
      - name: xtrabackup
        image: gcr.io/google-samples/xtrabackup:1.0
        ports:
        - name: xtrabackup
          containerPort: 3307
        command:
        - bash
        - "-c"
        - |
          set -ex
          cd /var/lib/mysql
          
          # 从备份信息文件里读取MASTER_LOG_FILEM和MASTER_LOG_POS这两个字段的值,用来拼装集群初始化SQL
          if [[ -f xtrabackup_slave_info ]]; then
            # 如果xtrabackup_slave_info文件存在,说明这个备份数据来自于另一个Slave节点。这种情况下,XtraBackup工具在备份的时候,就已经在这个文件里自动生成了"CHANGE MASTER TO" SQL语句。所以,我们只需要把这个文件重命名为change_master_to.sql.in,后面直接使用即可
            mv xtrabackup_slave_info change_master_to.sql.in
            # 所以,也就用不着xtrabackup_binlog_info了
            rm -f xtrabackup_binlog_info
          elif [[ -f xtrabackup_binlog_info ]]; then
            # 如果只存在xtrabackup_binlog_inf文件,那说明备份来自于Master节点,我们就需要解析这个备份信息文件,读取所需的两个字段的值
            [[ `cat xtrabackup_binlog_info` =~ ^(.*?)[[:space:]]+(.*?)$ ]] || exit 1
            rm xtrabackup_binlog_info
            # 把两个字段的值拼装成SQL,写入change_master_to.sql.in文件
            echo "CHANGE MASTER TO MASTER_LOG_FILE='${BASH_REMATCH[1]}',\
                  MASTER_LOG_POS=${BASH_REMATCH[2]}" > change_master_to.sql.in
          fi
          
          # 如果change_master_to.sql.in,就意味着需要做集群初始化工作
          if [[ -f change_master_to.sql.in ]]; then
            # 但一定要先等MySQL容器启动之后才能进行下一步连接MySQL的操作
            echo "Waiting for mysqld to be ready (accepting connections)"
            until mysql -h 127.0.0.1 -e "SELECT 1"; do sleep 1; done
            
            echo "Initializing replication from clone position"
            # 将文件change_master_to.sql.in改个名字,防止这个Container重启的时候,因为又找到了change_master_to.sql.in,从而重复执行一遍这个初始化流程
            mv change_master_to.sql.in change_master_to.sql.orig
            # 使用change_master_to.sql.orig的内容,也是就是前面拼装的SQL,组成一个完整的初始化和启动Slave的SQL语句
            mysql -h 127.0.0.1 <<EOF
          $(<change_master_to.sql.orig),
            MASTER_HOST='mysql-0.mysql',
            MASTER_USER='root',
            MASTER_PASSWORD='',
            MASTER_CONNECT_RETRY=10;
          START SLAVE;
          EOF
          fi
          
          # 使用ncat监听3307端口。它的作用是,在收到传输请求的时候,直接执行"xtrabackup --backup"命令,备份MySQL的数据并发送给请求者
          exec ncat --listen --keep-open --send-only --max-conns=1 3307 -c \
            "xtrabackup --backup --slave-info --stream=xbstream --host=127.0.0.1 --user=root"
        volumeMounts:
        - name: data
          mountPath: /var/lib/mysql
          subPath: mysql
        - name: conf
          mountPath: /etc/mysql/conf.d

可以看到,在这个名叫xtrabackup的sidecar容器的启动命令里,其实实现了两部分工作。

第一部分工作,当然是MySQL节点的初始化工作。这个初始化需要使用的SQL,是sidecar容器拼装出来、保存在一个名为change_master_to.sql.in的文件里的,具体过程如下所示:

sidecar容器首先会判断当前Pod的/var/lib/mysql目录下,是否有xtrabackup_slave_info这个备份信息文件。

  • 如果有,则说明这个目录下的备份数据是由一个Slave节点生成的。这种情况下,XtraBackup工具在备份的时候,就已经在这个文件里自动生成了"CHANGE MASTER TO" SQL语句。所以,我们只需要把这个文件重命名为change_master_to.sql.in,后面直接使用即可。
  • 如果没有xtrabackup_slave_info文件、但是存在xtrabackup_binlog_info文件,那就说明备份数据来自于Master节点。这种情况下,sidecar容器就需要解析这个备份信息文件,读取MASTER_LOG_FILE和MASTER_LOG_POS这两个字段的值,用它们拼装出初始化SQL语句,然后把这句SQL写入到change_master_to.sql.in文件中。

接下来,sidecar容器就可以执行初始化了。从上面的叙述中可以看到,只要这个change_master_to.sql.in文件存在,那就说明接下来需要进行集群初始化操作。

所以,这时候,sidecar容器只需要读取并执行change_master_to.sql.in里面的“CHANGE MASTER TO”指令,再执行一句START SLAVE命令,一个Slave节点就被成功启动了。

需要注意的是:Pod里的容器并没有先后顺序,所以在执行初始化SQL之前,必须先执行一句SQL(select 1)来检查一下MySQL服务是否已经可用。

当然,上述这些初始化操作完成后,我们还要删除掉前面用到的这些备份信息文件。否则,下次这个容器重启时,就会发现这些文件存在,所以又会重新执行一次数据恢复和集群初始化的操作,这是不对的。

同理,change_master_to.sql.in在使用后也要被重命名,以免容器重启时因为发现这个文件存在又执行一遍初始化。

在完成MySQL节点的初始化后,这个sidecar容器的第二个工作,则是启动一个数据传输服务。

具体做法是:sidecar容器会使用ncat命令启动一个工作在3307端口上的网络发送服务。一旦收到数据传输请求时,sidecar容器就会调用xtrabackup --backup指令备份当前MySQL的数据,然后把这些备份数据返回给请求者。这就是为什么我们在InitContainer里定义数据拷贝的时候,访问的是“上一个MySQL节点”的3307端口。

值得一提的是,由于sidecar容器和MySQL容器同处于一个Pod里,所以它是直接通过Localhost来访问和备份MySQL容器里的数据的,非常方便。

同样地,我在这里举例用的只是一种备份方法而已,你完全可以选择其他自己喜欢的方案。比如,你可以使用innobackupex命令做数据备份和准备,它的使用方法几乎与本文的备份方法一样。

至此,我们也就翻越了“第三座大山”,完成了Slave节点第一次启动前的初始化工作。

扳倒了这“三座大山”后,我们终于可以定义Pod里的主角,MySQL容器了。有了前面这些定义和初始化工作,MySQL容器本身的定义就非常简单了,如下所示:

      ...
      # template.spec
      containers:
      - name: mysql
        image: mysql:5.7
        env:
        - name: MYSQL_ALLOW_EMPTY_PASSWORD
          value: "1"
        ports:
        - name: mysql
          containerPort: 3306
        volumeMounts:
        - name: data
          mountPath: /var/lib/mysql
          subPath: mysql
        - name: conf
          mountPath: /etc/mysql/conf.d
        resources:
          requests:
            cpu: 500m
            memory: 1Gi
        livenessProbe:
          exec:
            command: ["mysqladmin", "ping"]
          initialDelaySeconds: 30
          periodSeconds: 10
          timeoutSeconds: 5
        readinessProbe:
          exec:
            # 通过TCP连接的方式进行健康检查
            command: ["mysql", "-h", "127.0.0.1", "-e", "SELECT 1"]
          initialDelaySeconds: 5
          periodSeconds: 2
          timeoutSeconds: 1

在这个容器的定义里,我们使用了一个标准的MySQL 5.7 的官方镜像。它的数据目录是/var/lib/mysql,配置文件目录是/etc/mysql/conf.d。

这时候,你应该能够明白,如果MySQL容器是Slave节点的话,它的数据目录里的数据,就来自于InitContainer从其他节点里拷贝而来的备份。它的配置文件目录/etc/mysql/conf.d里的内容,则来自于ConfigMap对应的Volume。而它的初始化工作,则是由同一个Pod里的sidecar容器完成的。这些操作,正是我刚刚为你讲述的大部分内容。

另外,我们为它定义了一个livenessProbe,通过mysqladmin ping命令来检查它是否健康;还定义了一个readinessProbe,通过查询SQL(select 1)来检查MySQL服务是否可用。当然,凡是readinessProbe检查失败的MySQL Pod,都会从Service里被摘除掉。

至此,一个完整的主从复制模式的MySQL集群就定义完了。

现在,我们就可以使用kubectl命令,尝试运行一下这个StatefulSet了。

首先,我们需要在Kubernetes集群里创建满足条件的PV。如果你使用的是我们在第11篇文章《从0到1:搭建一个完整的Kubernetes集群》里部署的Kubernetes集群的话,你可以按照如下方式使用存储插件Rook:

$ kubectl create -f rook-storage.yaml
$ cat rook-storage.yaml
apiVersion: ceph.rook.io/v1beta1
kind: Pool
metadata:
  name: replicapool
  namespace: rook-ceph
spec:
  replicated:
    size: 3
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: rook-ceph-block
provisioner: ceph.rook.io/block
parameters:
  pool: replicapool
  clusterNamespace: rook-ceph

在这里,我用到了StorageClass来完成这个操作。它的作用,是自动地为集群里存在的每一个PVC,调用存储插件(Rook)创建对应的PV,从而省去了我们手动创建PV的机械劳动。我在后续讲解容器存储的时候,会再详细介绍这个机制。

备注:在使用Rook的情况下,mysql-statefulset.yaml里的volumeClaimTemplates字段需要加上声明storageClassName=rook-ceph-block,才能使用到这个Rook提供的持久化存储。

然后,我们就可以创建这个StatefulSet了,如下所示:

$ kubectl create -f mysql-statefulset.yaml
$ kubectl get pod -l app=mysql
NAME      READY     STATUS    RESTARTS   AGE
mysql-0   2/2       Running   0          2m
mysql-1   2/2       Running   0          1m
mysql-2   2/2       Running   0          1m

可以看到,StatefulSet启动成功后,会有三个Pod运行。

接下来,我们可以尝试向这个MySQL集群发起请求,执行一些SQL操作来验证它是否正常:

$ kubectl run mysql-client --image=mysql:5.7 -i --rm --restart=Never --\
  mysql -h mysql-0.mysql <<EOF
CREATE DATABASE test;
CREATE TABLE test.messages (message VARCHAR(250));
INSERT INTO test.messages VALUES ('hello');
EOF

如上所示,我们通过启动一个容器,使用MySQL client执行了创建数据库和表、以及插入数据的操作。需要注意的是,我们连接的MySQL的地址必须是mysql-0.mysql(即:Master节点的DNS记录)。因为,只有Master节点才能处理写操作。

而通过连接mysql-read这个Service,我们就可以用SQL进行读操作,如下所示:

$ kubectl run mysql-client --image=mysql:5.7 -i -t --rm --restart=Never --\
 mysql -h mysql-read -e "SELECT * FROM test.messages"
Waiting for pod default/mysql-client to be running, status is Pending, pod ready: false
+---------+
| message |
+---------+
| hello   |
+---------+
pod "mysql-client" deleted

在有了StatefulSet以后,你就可以像Deployment那样,非常方便地扩展这个MySQL集群,比如:

$ kubectl scale statefulset mysql  --replicas=5

这时候,你就会发现新的Slave Pod mysql-3和mysql-4被自动创建了出来。

而如果你像如下所示的这样,直接连接mysql-3.mysql,即mysql-3这个Pod的DNS名字来进行查询操作:

$ kubectl run mysql-client --image=mysql:5.7 -i -t --rm --restart=Never --\
  mysql -h mysql-3.mysql -e "SELECT * FROM test.messages"
Waiting for pod default/mysql-client to be running, status is Pending, pod ready: false
+---------+
| message |
+---------+
| hello   |
+---------+
pod "mysql-client" deleted

就会看到,从StatefulSet为我们新创建的mysql-3上,同样可以读取到之前插入的记录。也就是说,我们的数据备份和恢复,都是有效的。

总结

在今天这篇文章中,我以MySQL集群为例,和你详细分享了一个实际的StatefulSet的编写过程。这个YAML文件的链接在这里,希望你能多花一些时间认真消化。

在这个过程中,有以下几个关键点(坑)特别值得你注意和体会。

  1. “人格分裂”:在解决需求的过程中,一定要记得思考,该Pod在扮演不同角色时的不同操作。

  2. “阅后即焚”:很多“有状态应用”的节点,只是在第一次启动的时候才需要做额外处理。所以,在编写YAML文件时,你一定要考虑“容器重启”的情况,不要让这一次的操作干扰到下一次的容器启动。

  3. “容器之间平等无序”:除非是InitContainer,否则一个Pod里的多个容器之间,是完全平等的。所以,你精心设计的sidecar,绝不能对容器的顺序做出假设,否则就需要进行前置检查。

最后,相信你也已经能够理解,StatefulSet其实是一种特殊的Deployment,只不过这个“Deployment”的每个Pod实例的名字里,都携带了一个唯一并且固定的编号。这个编号的顺序,固定了Pod的拓扑关系;这个编号对应的DNS记录,固定了Pod的访问方式;这个编号对应的PV,绑定了Pod与持久化存储的关系。所以,当Pod被删除重建时,这些“状态”都会保持不变。

而一旦你的应用没办法通过上述方式进行状态的管理,那就代表了StatefulSet已经不能解决它的部署问题了。这时候,我后面讲到的Operator,可能才是一个更好的选择。

思考题

如果我们现在的需求是:所有的读请求,只由Slave节点处理;所有的写请求,只由Master节点处理。那么,你需要在今天这篇文章的基础上再做哪些改动呢?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

21-容器化守护进程的意义:DaemonSet

你好,我是张磊。今天我和你分享的主题是:容器化守护进程的意义之DaemonSet。

在上一篇文章中,我和你详细分享了使用StatefulSet编排“有状态应用”的过程。从中不难看出,StatefulSet其实就是对现有典型运维业务的容器化抽象。也就是说,你一定有方法在不使用Kubernetes、甚至不使用容器的情况下,自己DIY一个类似的方案出来。但是,一旦涉及到升级、版本管理等更工程化的能力,Kubernetes的好处,才会更加凸现。

比如,如何对StatefulSet进行“滚动更新”(rolling update)?

很简单。你只要修改StatefulSet的Pod模板,就会自动触发“滚动更新”:

$ kubectl patch statefulset mysql --type='json' -p='[{"op": "replace", "path": "/spec/template/spec/containers/0/image", "value":"mysql:5.7.23"}]'
statefulset.apps/mysql patched

在这里,我使用了kubectl patch命令。它的意思是,以“补丁”的方式(JSON格式的)修改一个API对象的指定字段,也就是我在后面指定的“spec/template/spec/containers/0/image”。

这样,StatefulSet Controller就会按照与Pod编号相反的顺序,从最后一个Pod开始,逐一更新这个StatefulSet管理的每个Pod。而如果更新发生了错误,这次“滚动更新”就会停止。此外,StatefulSet的“滚动更新”还允许我们进行更精细的控制,比如金丝雀发布(Canary Deploy)或者灰度发布,这意味着应用的多个实例中被指定的一部分不会被更新到最新的版本。

这个字段,正是StatefulSet的spec.updateStrategy.rollingUpdate的partition字段。

比如,现在我将前面这个StatefulSet的partition字段设置为2:

$ kubectl patch statefulset mysql -p '{"spec":{"updateStrategy":{"type":"RollingUpdate","rollingUpdate":{"partition":2}}}}'
statefulset.apps/mysql patched

其中,kubectl patch命令后面的参数(JSON格式的),就是partition字段在API对象里的路径。所以,上述操作等同于直接使用 kubectl edit命令,打开这个对象,把partition字段修改为2。

这样,我就指定了当Pod模板发生变化的时候,比如MySQL镜像更新到5.7.23,那么只有序号大于或者等于2的Pod会被更新到这个版本。并且,如果你删除或者重启了序号小于2的Pod,等它再次启动后,也会保持原先的5.7.2版本,绝不会被升级到5.7.23版本。

StatefulSet可以说是Kubernetes项目中最为复杂的编排对象,希望你课后能认真消化,动手实践一下这个例子。

而在今天这篇文章中,我会为你重点讲解一个相对轻松的知识点:DaemonSet。

顾名思义,DaemonSet的主要作用,是让你在Kubernetes集群里,运行一个Daemon Pod。 所以,这个Pod有如下三个特征:

  1. 这个Pod运行在Kubernetes集群里的每一个节点(Node)上;

  2. 每个节点上只有一个这样的Pod实例;

  3. 当有新的节点加入Kubernetes集群后,该Pod会自动地在新节点上被创建出来;而当旧节点被删除后,它上面的Pod也相应地会被回收掉。

这个机制听起来很简单,但Daemon Pod的意义确实是非常重要的。我随便给你列举几个例子:

  1. 各种网络插件的Agent组件,都必须运行在每一个节点上,用来处理这个节点上的容器网络;

  2. 各种存储插件的Agent组件,也必须运行在每一个节点上,用来在这个节点上挂载远程存储目录,操作容器的Volume目录;

  3. 各种监控组件和日志组件,也必须运行在每一个节点上,负责这个节点上的监控信息和日志搜集。

更重要的是,跟其他编排对象不一样,DaemonSet开始运行的时机,很多时候比整个Kubernetes集群出现的时机都要早。

这个乍一听起来可能有点儿奇怪。但其实你来想一下:如果这个DaemonSet正是一个网络插件的Agent组件呢?

这个时候,整个Kubernetes集群里还没有可用的容器网络,所有Worker节点的状态都是NotReady(NetworkReady=false)。这种情况下,普通的Pod肯定不能运行在这个集群上。所以,这也就意味着DaemonSet的设计,必须要有某种“过人之处”才行。

为了弄清楚DaemonSet的工作原理,我们还是按照老规矩,先从它的API对象的定义说起。

apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: fluentd-elasticsearch
  namespace: kube-system
  labels:
    k8s-app: fluentd-logging
spec:
  selector:
    matchLabels:
      name: fluentd-elasticsearch
  template:
    metadata:
      labels:
        name: fluentd-elasticsearch
    spec:
      tolerations:
      - key: node-role.kubernetes.io/master
        effect: NoSchedule
      containers:
      - name: fluentd-elasticsearch
        image: k8s.gcr.io/fluentd-elasticsearch:1.20
        resources:
          limits:
            memory: 200Mi
          requests:
            cpu: 100m
            memory: 200Mi
        volumeMounts:
        - name: varlog
          mountPath: /var/log
        - name: varlibdockercontainers
          mountPath: /var/lib/docker/containers
          readOnly: true
      terminationGracePeriodSeconds: 30
      volumes:
      - name: varlog
        hostPath:
          path: /var/log
      - name: varlibdockercontainers
        hostPath:
          path: /var/lib/docker/containers

这个DaemonSet,管理的是一个fluentd-elasticsearch镜像的Pod。这个镜像的功能非常实用:通过fluentd将Docker容器里的日志转发到ElasticSearch中。

可以看到,DaemonSet跟Deployment其实非常相似,只不过是没有replicas字段;它也使用selector选择管理所有携带了name=fluentd-elasticsearch标签的Pod。

而这些Pod的模板,也是用template字段定义的。在这个字段中,我们定义了一个使用 fluentd-elasticsearch:1.20镜像的容器,而且这个容器挂载了两个hostPath类型的Volume,分别对应宿主机的/var/log目录和/var/lib/docker/containers目录。

显然,fluentd启动之后,它会从这两个目录里搜集日志信息,并转发给ElasticSearch保存。这样,我们通过ElasticSearch就可以很方便地检索这些日志了。

需要注意的是,Docker容器里应用的日志,默认会保存在宿主机的/var/lib/docker/containers/{{.容器ID}}/{{.容器ID}}-json.log文件里,所以这个目录正是fluentd的搜集目标。

那么,DaemonSet又是如何保证每个Node上有且只有一个被管理的Pod呢?

显然,这是一个典型的“控制器模型”能够处理的问题。

DaemonSet Controller,首先从Etcd里获取所有的Node列表,然后遍历所有的Node。这时,它就可以很容易地去检查,当前这个Node上是不是有一个携带了name=fluentd-elasticsearch标签的Pod在运行。

而检查的结果,可能有这么三种情况:

  1. 没有这种Pod,那么就意味着要在这个Node上创建这样一个Pod;

  2. 有这种Pod,但是数量大于1,那就说明要把多余的Pod从这个Node上删除掉;

  3. 正好只有一个这种Pod,那说明这个节点是正常的。

其中,删除节点(Node)上多余的Pod非常简单,直接调用Kubernetes API就可以了。

但是,如何在指定的Node上创建新Pod呢?

如果你已经熟悉了Pod API对象的话,那一定可以立刻说出答案:用nodeSelector,选择Node的名字即可。

nodeSelector:
    name: <Node名字>

没错。

不过,在Kubernetes项目里,nodeSelector其实已经是一个将要被废弃的字段了。因为,现在有了一个新的、功能更完善的字段可以代替它,即:nodeAffinity。我来举个例子:

apiVersion: v1
kind: Pod
metadata:
  name: with-node-affinity
spec:
  affinity:
    nodeAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
        nodeSelectorTerms:
        - matchExpressions:
          - key: metadata.name
            operator: In
            values:
            - node-geektime

在这个Pod里,我声明了一个spec.affinity字段,然后定义了一个nodeAffinity。其中,spec.affinity字段,是Pod里跟调度相关的一个字段。关于它的完整内容,我会在讲解调度策略的时候再详细阐述。

而在这里,我定义的nodeAffinity的含义是:

  1. requiredDuringSchedulingIgnoredDuringExecution:它的意思是说,这个nodeAffinity必须在每次调度的时候予以考虑。同时,这也意味着你可以设置在某些情况下不考虑这个nodeAffinity;

  2. 这个Pod,将来只允许运行在“metadata.name”是“node-geektime”的节点上。

在这里,你应该注意到nodeAffinity的定义,可以支持更加丰富的语法,比如operator: In(即:部分匹配;如果你定义operator: Equal,就是完全匹配),这也正是nodeAffinity会取代nodeSelector的原因之一。

备注:其实在大多数时候,这些Operator语义没啥用处。所以说,在学习开源项目的时候,一定要学会抓住“主线”。不要顾此失彼。

所以,我们的DaemonSet Controller会在创建Pod的时候,自动在这个Pod的API对象里,加上这样一个nodeAffinity定义。其中,需要绑定的节点名字,正是当前正在遍历的这个Node。

当然,DaemonSet并不需要修改用户提交的YAML文件里的Pod模板,而是在向Kubernetes发起请求之前,直接修改根据模板生成的Pod对象。这个思路,也正是我在前面讲解Pod对象时介绍过的。

此外,DaemonSet还会给这个Pod自动加上另外一个与调度相关的字段,叫作tolerations。这个字段意味着这个Pod,会“容忍”(Toleration)某些Node的“污点”(Taint)。

而DaemonSet自动加上的tolerations字段,格式如下所示:

apiVersion: v1
kind: Pod
metadata:
  name: with-toleration
spec:
  tolerations:
  - key: node.kubernetes.io/unschedulable
    operator: Exists
    effect: NoSchedule

这个Toleration的含义是:“容忍”所有被标记为unschedulable“污点”的Node;“容忍”的效果是允许调度。

备注:关于如何给一个Node标记上“污点”,以及这里具体的语法定义,我会在后面介绍调度器的时候做详细介绍。这里,你可以简单地把“污点”理解为一种特殊的Label。

而在正常情况下,被标记了unschedulable“污点”的Node,是不会有任何Pod被调度上去的(effect: NoSchedule)。可是,DaemonSet自动地给被管理的Pod加上了这个特殊的Toleration,就使得这些Pod可以忽略这个限制,继而保证每个节点上都会被调度一个Pod。当然,如果这个节点有故障的话,这个Pod可能会启动失败,而DaemonSet则会始终尝试下去,直到Pod启动成功。

这时,你应该可以猜到,我在前面介绍到的DaemonSet的“过人之处”,其实就是依靠Toleration实现的。

假如当前DaemonSet管理的,是一个网络插件的Agent Pod,那么你就必须在这个DaemonSet的YAML文件里,给它的Pod模板加上一个能够“容忍”node.kubernetes.io/network-unavailable“污点”的Toleration。正如下面这个例子所示:

...
template:
    metadata:
      labels:
        name: network-plugin-agent
    spec:
      tolerations:
      - key: node.kubernetes.io/network-unavailable
        operator: Exists
        effect: NoSchedule

在Kubernetes项目中,当一个节点的网络插件尚未安装时,这个节点就会被自动加上名为node.kubernetes.io/network-unavailable的“污点”。

而通过这样一个Toleration,调度器在调度这个Pod的时候,就会忽略当前节点上的“污点”,从而成功地将网络插件的Agent组件调度到这台机器上启动起来。

这种机制,正是我们在部署Kubernetes集群的时候,能够先部署Kubernetes本身、再部署网络插件的根本原因:因为当时我们所创建的Weave的YAML,实际上就是一个DaemonSet。

这里,你也可以再回顾一下第11篇文章《从0到1:搭建一个完整的Kubernetes集群》中的相关内容。

至此,通过上面这些内容,你应该能够明白,DaemonSet其实是一个非常简单的控制器。在它的控制循环中,只需要遍历所有节点,然后根据节点上是否有被管理Pod的情况,来决定是否要创建或者删除一个Pod。

只不过,在创建每个Pod的时候,DaemonSet会自动给这个Pod加上一个nodeAffinity,从而保证这个Pod只会在指定节点上启动。同时,它还会自动给这个Pod加上一个Toleration,从而忽略节点的unschedulable“污点”。

当然,你也可以在Pod模板里加上更多种类的Toleration,从而利用DaemonSet达到自己的目的。比如,在这个fluentd-elasticsearch DaemonSet里,我就给它加上了这样的Toleration:

tolerations:
- key: node-role.kubernetes.io/master
  effect: NoSchedule

这是因为在默认情况下,Kubernetes集群不允许用户在Master节点部署Pod。因为,Master节点默认携带了一个叫作node-role.kubernetes.io/master的“污点”。所以,为了能在Master节点上部署DaemonSet的Pod,我就必须让这个Pod“容忍”这个“污点”。

在理解了DaemonSet的工作原理之后,接下来我就通过一个具体的实践来帮你更深入地掌握DaemonSet的使用方法。

备注:需要注意的是,在Kubernetes v1.11之前,由于调度器尚不完善,DaemonSet是由DaemonSet Controller自行调度的,即它会直接设置Pod的spec.nodename字段,这样就可以跳过调度器了。但是,这样的做法很快就会被废除,所以在这里我也不推荐你再花时间学习这个流程了。

首先,创建这个DaemonSet对象:

$ kubectl create -f fluentd-elasticsearch.yaml

需要注意的是,在DaemonSet上,我们一般都应该加上resources字段,来限制它的CPU和内存使用,防止它占用过多的宿主机资源。

而创建成功后,你就能看到,如果有N个节点,就会有N个fluentd-elasticsearch Pod在运行。比如在我们的例子里,会有两个Pod,如下所示:

$ kubectl get pod -n kube-system -l name=fluentd-elasticsearch
NAME                          READY     STATUS    RESTARTS   AGE
fluentd-elasticsearch-dqfv9   1/1       Running   0          53m
fluentd-elasticsearch-pf9z5   1/1       Running   0          53m

而如果你此时通过kubectl get查看一下Kubernetes集群里的DaemonSet对象:

$ kubectl get ds -n kube-system fluentd-elasticsearch
NAME                    DESIRED   CURRENT   READY     UP-TO-DATE   AVAILABLE   NODE SELECTOR   AGE
fluentd-elasticsearch   2         2         2         2            2           <none>          1h

备注:Kubernetes里比较长的API对象都有短名字,比如DaemonSet对应的是ds,Deployment对应的是deploy。

就会发现DaemonSet和Deployment一样,也有DESIRED、CURRENT等多个状态字段。这也就意味着,DaemonSet可以像Deployment那样,进行版本管理。这个版本,可以使用kubectl rollout history看到:

$ kubectl rollout history daemonset fluentd-elasticsearch -n kube-system
daemonsets "fluentd-elasticsearch"
REVISION  CHANGE-CAUSE
1         <none>

接下来,我们来把这个DaemonSet的容器镜像版本到v2.2.0:

$ kubectl set image ds/fluentd-elasticsearch fluentd-elasticsearch=k8s.gcr.io/fluentd-elasticsearch:v2.2.0 --record -n=kube-system

这个kubectl set image命令里,第一个fluentd-elasticsearch是DaemonSet的名字,第二个fluentd-elasticsearch是容器的名字。

这时候,我们可以使用kubectl rollout status命令看到这个“滚动更新”的过程,如下所示:

$ kubectl rollout status ds/fluentd-elasticsearch -n kube-system
Waiting for daemon set "fluentd-elasticsearch" rollout to finish: 0 out of 2 new pods have been updated...
Waiting for daemon set "fluentd-elasticsearch" rollout to finish: 0 out of 2 new pods have been updated...
Waiting for daemon set "fluentd-elasticsearch" rollout to finish: 1 of 2 updated pods are available...
daemon set "fluentd-elasticsearch" successfully rolled out

注意,由于这一次我在升级命令后面加上了–record参数,所以这次升级使用到的指令就会自动出现在DaemonSet的rollout history里面,如下所示:

$ kubectl rollout history daemonset fluentd-elasticsearch -n kube-system
daemonsets "fluentd-elasticsearch"
REVISION  CHANGE-CAUSE
1         <none>
2         kubectl set image ds/fluentd-elasticsearch fluentd-elasticsearch=k8s.gcr.io/fluentd-elasticsearch:v2.2.0 --namespace=kube-system --record=true

有了版本号,你也就可以像Deployment一样,将DaemonSet回滚到某个指定的历史版本了。

而我在前面的文章中讲解Deployment对象的时候,曾经提到过,Deployment管理这些版本,靠的是“一个版本对应一个ReplicaSet对象”。可是,DaemonSet控制器操作的直接就是Pod,不可能有ReplicaSet这样的对象参与其中。那么,它的这些版本又是如何维护的呢?

所谓,一切皆对象!

在Kubernetes项目中,任何你觉得需要记录下来的状态,都可以被用API对象的方式实现。当然,“版本”也不例外。

Kubernetes v1.7之后添加了一个API对象,名叫ControllerRevision,专门用来记录某种Controller对象的版本。比如,你可以通过如下命令查看fluentd-elasticsearch对应的ControllerRevision:

$ kubectl get controllerrevision -n kube-system -l name=fluentd-elasticsearch
NAME                               CONTROLLER                             REVISION   AGE
fluentd-elasticsearch-64dc6799c9   daemonset.apps/fluentd-elasticsearch   2          1h

而如果你使用kubectl describe查看这个ControllerRevision对象:

$ kubectl describe controllerrevision fluentd-elasticsearch-64dc6799c9 -n kube-system
Name:         fluentd-elasticsearch-64dc6799c9
Namespace:    kube-system
Labels:       controller-revision-hash=2087235575
              name=fluentd-elasticsearch
Annotations:  deprecated.daemonset.template.generation=2
              kubernetes.io/change-cause=kubectl set image ds/fluentd-elasticsearch fluentd-elasticsearch=k8s.gcr.io/fluentd-elasticsearch:v2.2.0 --record=true --namespace=kube-system
API Version:  apps/v1
Data:
  Spec:
    Template:
      $ Patch:  replace
      Metadata:
        Creation Timestamp:  <nil>
        Labels:
          Name:  fluentd-elasticsearch
      Spec:
        Containers:
          Image:              k8s.gcr.io/fluentd-elasticsearch:v2.2.0
          Image Pull Policy:  IfNotPresent
          Name:               fluentd-elasticsearch
...
Revision:                  2
Events:                    <none>

就会看到,这个ControllerRevision对象,实际上是在Data字段保存了该版本对应的完整的DaemonSet的API对象。并且,在Annotation字段保存了创建这个对象所使用的kubectl命令。

接下来,我们可以尝试将这个DaemonSet回滚到Revision=1时的状态:

$ kubectl rollout undo daemonset fluentd-elasticsearch --to-revision=1 -n kube-system
daemonset.extensions/fluentd-elasticsearch rolled back

这个kubectl rollout undo操作,实际上相当于读取到了Revision=1的ControllerRevision对象保存的Data字段。而这个Data字段里保存的信息,就是Revision=1时这个DaemonSet的完整API对象。

所以,现在DaemonSet Controller就可以使用这个历史API对象,对现有的DaemonSet做一次PATCH操作(等价于执行一次kubectl apply -f “旧的DaemonSet对象”),从而把这个DaemonSet“更新”到一个旧版本。

这也是为什么,在执行完这次回滚完成后,你会发现,DaemonSet的Revision并不会从Revision=2退回到1,而是会增加成Revision=3。这是因为,一个新的ControllerRevision被创建了出来。

总结

在今天这篇文章中,我首先简单介绍了StatefulSet的“滚动更新”,然后重点讲解了本专栏的第三个重要编排对象:DaemonSet。

相比于Deployment,DaemonSet只管理Pod对象,然后通过nodeAffinity和Toleration这两个调度器的小功能,保证了每个节点上有且只有一个Pod。这个控制器的实现原理简单易懂,希望你能够快速掌握。

与此同时,DaemonSet使用ControllerRevision,来保存和管理自己对应的“版本”。这种“面向API对象”的设计思路,大大简化了控制器本身的逻辑,也正是Kubernetes项目“声明式API”的优势所在。

而且,相信聪明的你此时已经想到了,StatefulSet也是直接控制Pod对象的,那么它是不是也在使用ControllerRevision进行版本管理呢?

没错。在Kubernetes项目里,ControllerRevision其实是一个通用的版本管理对象。这样,Kubernetes项目就巧妙地避免了每种控制器都要维护一套冗余的代码和逻辑的问题。

思考题

我在文中提到,在Kubernetes v1.11之前,DaemonSet所管理的Pod的调度过程,实际上都是由DaemonSet Controller自己而不是由调度器完成的。你能说出这其中有哪些原因吗?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

22-撬动离线业务:Job与CronJob

你好,我是张磊。今天我和你分享的主题是:撬动离线业务之Job与CronJob。

在前面的几篇文章中,我和你详细分享了Deployment、StatefulSet,以及DaemonSet这三个编排概念。你有没有发现它们的共同之处呢?

实际上,它们主要编排的对象,都是“在线业务”,即:Long Running Task(长作业)。比如,我在前面举例时常用的Nginx、Tomcat,以及MySQL等等。这些应用一旦运行起来,除非出错或者停止,它的容器进程会一直保持在Running状态。

但是,有一类作业显然不满足这样的条件,这就是“离线业务”,或者叫作Batch Job(计算业务)。这种业务在计算完成后就直接退出了,而此时如果你依然用Deployment来管理这种业务的话,就会发现Pod会在计算结束后退出,然后被Deployment Controller不断地重启;而像“滚动更新”这样的编排功能,更无从谈起了。

所以,早在Borg项目中,Google就已经对作业进行了分类处理,提出了LRS(Long Running Service)和Batch Jobs两种作业形态,对它们进行“分别管理”和“混合调度”。

不过,在2015年Borg论文刚刚发布的时候,Kubernetes项目并不支持对Batch Job的管理。直到v1.4版本之后,社区才逐步设计出了一个用来描述离线业务的API对象,它的名字就是:Job。

Job API对象的定义非常简单,我来举个例子,如下所示:

apiVersion: batch/v1
kind: Job
metadata:
  name: pi
spec:
  template:
    spec:
      containers:
      - name: pi
        image: resouer/ubuntu-bc
        command: ["sh", "-c", "echo 'scale=10000; 4*a(1)' | bc -l "]
      restartPolicy: Never
  backoffLimit: 4

此时,相信你对Kubernetes的API对象已经不再陌生了。在这个Job的YAML文件里,你肯定一眼就会看到一位“老熟人”:Pod模板,即spec.template字段。

在这个Pod模板中,我定义了一个Ubuntu镜像的容器(准确地说,是一个安装了bc命令的Ubuntu镜像),它运行的程序是:

echo "scale=10000; 4*a(1)" | bc -l

其中,bc命令是Linux里的“计算器”;-l表示,我现在要使用标准数学库;而a(1),则是调用数学库中的arctangent函数,计算atan(1)。这是什么意思呢?

中学知识告诉我们:tan(π/4) = 1。所以,4*atan(1)正好就是π,也就是3.1415926…。

备注:如果你不熟悉这个知识也不必担心,我也是在查阅资料后才知道的。

所以,这其实就是一个计算π值的容器。而通过scale=10000,我指定了输出的小数点后的位数是10000。在我的计算机上,这个计算大概用时1分54秒。

但是,跟其他控制器不同的是,Job对象并不要求你定义一个spec.selector来描述要控制哪些Pod。具体原因,我马上会讲解到。

现在,我们就可以创建这个Job了:

$ kubectl create -f job.yaml

在成功创建后,我们来查看一下这个Job对象,如下所示:

$ kubectl describe jobs/pi
Name:             pi
Namespace:        default
Selector:         controller-uid=c2db599a-2c9d-11e6-b324-0209dc45a495
Labels:           controller-uid=c2db599a-2c9d-11e6-b324-0209dc45a495
                  job-name=pi
Annotations:      <none>
Parallelism:      1
Completions:      1
..
Pods Statuses:    0 Running / 1 Succeeded / 0 Failed
Pod Template:
  Labels:       controller-uid=c2db599a-2c9d-11e6-b324-0209dc45a495
                job-name=pi
  Containers:
   ...
  Volumes:              <none>
Events:
  FirstSeen    LastSeen    Count    From            SubobjectPath    Type        Reason            Message
  ---------    --------    -----    ----            -------------    --------    ------            -------
  1m           1m          1        {job-controller }                Normal      SuccessfulCreate  Created pod: pi-rq5rl

可以看到,这个Job对象在创建后,它的Pod模板,被自动加上了一个controller-uid=<一个随机字符串>这样的Label。而这个Job对象本身,则被自动加上了这个Label对应的Selector,从而 保证了Job与它所管理的Pod之间的匹配关系。

而Job Controller之所以要使用这种携带了UID的Label,就是为了避免不同Job对象所管理的Pod发生重合。需要注意的是,这种自动生成的Label对用户来说并不友好,所以不太适合推广到Deployment等长作业编排对象上。

接下来,我们可以看到这个Job创建的Pod进入了Running状态,这意味着它正在计算Pi的值。

$ kubectl get pods
NAME                                READY     STATUS    RESTARTS   AGE
pi-rq5rl                            1/1       Running   0          10s

而几分钟后计算结束,这个Pod就会进入Completed状态:

$ kubectl get pods
NAME                                READY     STATUS      RESTARTS   AGE
pi-rq5rl                            0/1       Completed   0          4m

这也是我们需要在Pod模板中定义restartPolicy=Never的原因:离线计算的Pod永远都不应该被重启,否则它们会再重新计算一遍。

事实上,restartPolicy在Job对象里只允许被设置为Never和OnFailure;而在Deployment对象里,restartPolicy则只允许被设置为Always。

此时,我们通过kubectl logs查看一下这个Pod的日志,就可以看到计算得到的Pi值已经被打印了出来:

$ kubectl logs pi-rq5rl
3.141592653589793238462643383279...

这时候,你一定会想到这样一个问题,如果这个离线作业失败了要怎么办?

比如,我们在这个例子中定义了restartPolicy=Never,那么离线作业失败后Job Controller就会不断地尝试创建一个新Pod,如下所示:

$ kubectl get pods
NAME                                READY     STATUS              RESTARTS   AGE
pi-55h89                            0/1       ContainerCreating   0          2s
pi-tqbcz                            0/1       Error               0          5s

可以看到,这时候会不断地有新Pod被创建出来。

当然,这个尝试肯定不能无限进行下去。所以,我们就在Job对象的spec.backoffLimit字段里定义了重试次数为4(即,backoffLimit=4),而这个字段的默认值是6。

需要注意的是,Job Controller重新创建Pod的间隔是呈指数增加的,即下一次重新创建Pod的动作会分别发生在10 s、20 s、40 s …后。

而如果你定义的restartPolicy=OnFailure,那么离线作业失败后,Job Controller就不会去尝试创建新的Pod。但是,它会不断地尝试重启Pod里的容器。这也正好对应了restartPolicy的含义(你也可以借此机会再回顾一下第15篇文章《深入解析Pod对象(二):使用进阶》中的相关内容)。

如前所述,当一个Job的Pod运行结束后,它会进入Completed状态。但是,如果这个Pod因为某种原因一直不肯结束呢?

在Job的API对象里,有一个spec.activeDeadlineSeconds字段可以设置最长运行时间,比如:

spec:
 backoffLimit: 5
 activeDeadlineSeconds: 100

一旦运行超过了100 s,这个Job的所有Pod都会被终止。并且,你可以在Pod的状态里看到终止的原因是reason: DeadlineExceeded。

以上,就是一个Job API对象最主要的概念和用法了。不过,离线业务之所以被称为Batch Job,当然是因为它们可以以“Batch”,也就是并行的方式去运行。

接下来,我就来为你讲解一下Job Controller对并行作业的控制方法。

在Job对象中,负责并行控制的参数有两个:

  1. spec.parallelism,它定义的是一个Job在任意时间最多可以启动多少个Pod同时运行;

  2. spec.completions,它定义的是Job至少要完成的Pod数目,即Job的最小完成数。

这两个参数听起来有点儿抽象,所以我准备了一个例子来帮助你理解。

现在,我在之前计算Pi值的Job里,添加这两个参数:

apiVersion: batch/v1
kind: Job
metadata:
  name: pi
spec:
  parallelism: 2
  completions: 4
  template:
    spec:
      containers:
      - name: pi
        image: resouer/ubuntu-bc
        command: ["sh", "-c", "echo 'scale=5000; 4*a(1)' | bc -l "]
      restartPolicy: Never
  backoffLimit: 4

这样,我们就指定了这个Job最大的并行数是2,而最小的完成数是4。

接下来,我们来创建这个Job对象:

$ kubectl create -f job.yaml

可以看到,这个Job其实也维护了两个状态字段,即DESIRED和SUCCESSFUL,如下所示:

$ kubectl get job
NAME      DESIRED   SUCCESSFUL   AGE
pi        4         0            3s

其中,DESIRED的值,正是completions定义的最小完成数。

然后,我们可以看到,这个Job首先创建了两个并行运行的Pod来计算Pi:

$ kubectl get pods
NAME       READY     STATUS    RESTARTS   AGE
pi-5mt88   1/1       Running   0          6s
pi-gmcq5   1/1       Running   0          6s

而在40 s后,这两个Pod相继完成计算。

这时我们可以看到,每当有一个Pod完成计算进入Completed状态时,就会有一个新的Pod被自动创建出来,并且快速地从Pending状态进入到ContainerCreating状态:

$ kubectl get pods
NAME       READY     STATUS    RESTARTS   AGE
pi-gmcq5   0/1       Completed   0         40s
pi-84ww8   0/1       Pending   0         0s
pi-5mt88   0/1       Completed   0         41s
pi-62rbt   0/1       Pending   0         0s

$ kubectl get pods
NAME       READY     STATUS    RESTARTS   AGE
pi-gmcq5   0/1       Completed   0         40s
pi-84ww8   0/1       ContainerCreating   0         0s
pi-5mt88   0/1       Completed   0         41s
pi-62rbt   0/1       ContainerCreating   0         0s

紧接着,Job Controller第二次创建出来的两个并行的Pod也进入了Running状态:

$ kubectl get pods
NAME       READY     STATUS      RESTARTS   AGE
pi-5mt88   0/1       Completed   0          54s
pi-62rbt   1/1       Running     0          13s
pi-84ww8   1/1       Running     0          14s
pi-gmcq5   0/1       Completed   0          54s

最终,后面创建的这两个Pod也完成了计算,进入了Completed状态。

这时,由于所有的Pod均已经成功退出,这个Job也就执行完了,所以你会看到它的SUCCESSFUL字段的值变成了4:

$ kubectl get pods
NAME       READY     STATUS      RESTARTS   AGE
pi-5mt88   0/1       Completed   0          5m
pi-62rbt   0/1       Completed   0          4m
pi-84ww8   0/1       Completed   0          4m
pi-gmcq5   0/1       Completed   0          5m

$ kubectl get job
NAME      DESIRED   SUCCESSFUL   AGE
pi        4         4            5m

通过上述Job的DESIRED和SUCCESSFUL字段的关系,我们就可以很容易地理解Job Controller的工作原理了。

首先,Job Controller控制的对象,直接就是Pod。

其次,Job Controller在控制循环中进行的调谐(Reconcile)操作,是根据实际在Running状态Pod的数目、已经成功退出的Pod的数目,以及parallelism、completions参数的值共同计算出在这个周期里,应该创建或者删除的Pod数目,然后调用Kubernetes API来执行这个操作。

以创建Pod为例。在上面计算Pi值的这个例子中,当Job一开始创建出来时,实际处于Running状态的Pod数目=0,已经成功退出的Pod数目=0,而用户定义的completions,也就是最终用户需要的Pod数目=4。

所以,在这个时刻,需要创建的Pod数目 = 最终需要的Pod数目 - 实际在Running状态Pod数目 - 已经成功退出的Pod数目 = 4 - 0 - 0= 4。也就是说,Job Controller需要创建4个Pod来纠正这个不一致状态。

可是,我们又定义了这个Job的parallelism=2。也就是说,我们规定了每次并发创建的Pod个数不能超过2个。所以,Job Controller会对前面的计算结果做一个修正,修正后的期望创建的Pod数目应该是:2个。

这时候,Job Controller就会并发地向kube-apiserver发起两个创建Pod的请求。

类似地,如果在这次调谐周期里,Job Controller发现实际在Running状态的Pod数目,比parallelism还大,那么它就会删除一些Pod,使两者相等。

综上所述,Job Controller实际上控制了,作业执行的并行度,以及总共需要完成的任务数这两个重要参数。而在实际使用时,你需要根据作业的特性,来决定并行度(parallelism)和任务数(completions)的合理取值。

接下来,我再和你分享三种常用的、使用Job对象的方法。

第一种用法,也是最简单粗暴的用法:外部管理器+Job模板。

这种模式的特定用法是:把Job的YAML文件定义为一个“模板”,然后用一个外部工具控制这些“模板”来生成Job。这时,Job的定义方式如下所示:

apiVersion: batch/v1
kind: Job
metadata:
  name: process-item-$ITEM
  labels:
    jobgroup: jobexample
spec:
  template:
    metadata:
      name: jobexample
      labels:
        jobgroup: jobexample
    spec:
      containers:
      - name: c
        image: busybox
        command: ["sh", "-c", "echo Processing item $ITEM && sleep 5"]
      restartPolicy: Never

可以看到,我们在这个Job的YAML里,定义了$ITEM这样的“变量”。

所以,在控制这种Job时,我们只要注意如下两个方面即可:

  1. 创建Job时,替换掉$ITEM这样的变量;

  2. 所有来自于同一个模板的Job,都有一个jobgroup: jobexample标签,也就是说这一组Job使用这样一个相同的标识。

而做到第一点非常简单。比如,你可以通过这样一句shell把$ITEM替换掉:

$ mkdir ./jobs
$ for i in apple banana cherry
do
  cat job-tmpl.yaml | sed "s/\$ITEM/$i/" > ./jobs/job-$i.yaml
done

这样,一组来自于同一个模板的不同Job的yaml就生成了。接下来,你就可以通过一句kubectl create指令创建这些Job了:

$ kubectl create -f ./jobs
$ kubectl get pods -l jobgroup=jobexample
NAME                        READY     STATUS      RESTARTS   AGE
process-item-apple-kixwv    0/1       Completed   0          4m
process-item-banana-wrsf7   0/1       Completed   0          4m
process-item-cherry-dnfu9   0/1       Completed   0          4m

这个模式看起来虽然很“傻”,但却是Kubernetes社区里使用Job的一个很普遍的模式。

原因很简单:大多数用户在需要管理Batch Job的时候,都已经有了一套自己的方案,需要做的往往就是集成工作。这时候,Kubernetes项目对这些方案来说最有价值的,就是Job这个API对象。所以,你只需要编写一个外部工具(等同于我们这里的for循环)来管理这些Job即可。

这种模式最典型的应用,就是TensorFlow社区的KubeFlow项目。

很容易理解,在这种模式下使用Job对象,completions和parallelism这两个字段都应该使用默认值1,而不应该由我们自行设置。而作业Pod的并行控制,应该完全交由外部工具来进行管理(比如,KubeFlow)。

第二种用法:拥有固定任务数目的并行Job

这种模式下,我只关心最后是否有指定数目(spec.completions)个任务成功退出。至于执行时的并行度是多少,我并不关心。

比如,我们这个计算Pi值的例子,就是这样一个典型的、拥有固定任务数目(completions=4)的应用场景。 它的parallelism值是2;或者,你可以干脆不指定parallelism,直接使用默认的并行度(即:1)。

此外,你还可以使用一个工作队列(Work Queue)进行任务分发。这时,Job的YAML文件定义如下所示:

apiVersion: batch/v1
kind: Job
metadata:
  name: job-wq-1
spec:
  completions: 8
  parallelism: 2
  template:
    metadata:
      name: job-wq-1
    spec:
      containers:
      - name: c
        image: myrepo/job-wq-1
        env:
        - name: BROKER_URL
          value: amqp://guest:guest@rabbitmq-service:5672
        - name: QUEUE
          value: job1
      restartPolicy: OnFailure

我们可以看到,它的completions的值是:8,这意味着我们总共要处理的任务数目是8个。也就是说,总共会有8个任务会被逐一放入工作队列里(你可以运行一个外部小程序作为生产者,来提交任务)。

在这个实例中,我选择充当工作队列的是一个运行在Kubernetes里的RabbitMQ。所以,我们需要在Pod模板里定义BROKER_URL,来作为消费者。

所以,一旦你用kubectl create创建了这个Job,它就会以并发度为2的方式,每两个Pod一组,创建出8个Pod。每个Pod都会去连接BROKER_URL,从RabbitMQ里读取任务,然后各自进行处理。这个Pod里的执行逻辑,我们可以用这样一段伪代码来表示:

/* job-wq-1的伪代码 */
queue := newQueue($BROKER_URL, $QUEUE)
task := queue.Pop()
process(task)
exit

可以看到,每个Pod只需要将任务信息读取出来,处理完成,然后退出即可。而作为用户,我只关心最终一共有8个计算任务启动并且退出,只要这个目标达到,我就认为整个Job处理完成了。所以说,这种用法,对应的就是“任务总数固定”的场景。

第三种用法,也是很常用的一个用法:指定并行度(parallelism),但不设置固定的completions的值。

此时,你就必须自己想办法,来决定什么时候启动新Pod,什么时候Job才算执行完成。在这种情况下,任务的总数是未知的,所以你不仅需要一个工作队列来负责任务分发,还需要能够判断工作队列已经为空(即:所有的工作已经结束了)。

这时候,Job的定义基本上没变化,只不过是不再需要定义completions的值了而已:

apiVersion: batch/v1
kind: Job
metadata:
  name: job-wq-2
spec:
  parallelism: 2
  template:
    metadata:
      name: job-wq-2
    spec:
      containers:
      - name: c
        image: gcr.io/myproject/job-wq-2
        env:
        - name: BROKER_URL
          value: amqp://guest:guest@rabbitmq-service:5672
        - name: QUEUE
          value: job2
      restartPolicy: OnFailure

而对应的Pod的逻辑会稍微复杂一些,我可以用这样一段伪代码来描述:

/* job-wq-2的伪代码 */
for !queue.IsEmpty($BROKER_URL, $QUEUE) {
  task := queue.Pop()
  process(task)
}
print("Queue empty, exiting")
exit

由于任务数目的总数不固定,所以每一个Pod必须能够知道,自己什么时候可以退出。比如,在这个例子中,我简单地以“队列为空”,作为任务全部完成的标志。所以说,这种用法,对应的是“任务总数不固定”的场景。

不过,在实际的应用中,你需要处理的条件往往会非常复杂。比如,任务完成后的输出、每个任务Pod之间是不是有资源的竞争和协同等等。

所以,在今天这篇文章中,我就不再展开Job的用法了。因为,在实际场景里,要么干脆就用第一种用法来自己管理作业;要么,这些任务Pod之间的关系就不那么“单纯”,甚至还是“有状态应用”(比如,任务的输入/输出是在持久化数据卷里)。在这种情况下,我在后面要重点讲解的Operator,加上Job对象一起,可能才能更好地满足实际离线任务的编排需求。

最后,我再来和你分享一个非常有用的Job对象,叫作:CronJob。

顾名思义,CronJob描述的,正是定时任务。它的API对象,如下所示:

apiVersion: batch/v1beta1
kind: CronJob
metadata:
  name: hello
spec:
  schedule: "*/1 * * * *"
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: hello
            image: busybox
            args:
            - /bin/sh
            - -c
            - date; echo Hello from the Kubernetes cluster
          restartPolicy: OnFailure

在这个YAML文件中,最重要的关键词就是jobTemplate。看到它,你一定恍然大悟,原来CronJob是一个Job对象的控制器(Controller)!

没错,CronJob与Job的关系,正如同Deployment与ReplicaSet的关系一样。CronJob是一个专门用来管理Job对象的控制器。只不过,它创建和删除Job的依据,是schedule字段定义的、一个标准的Unix Cron格式的表达式。

比如,"*/1 * * * *"。

这个Cron表达式里*/1中的*表示从0开始,/表示“每”,1表示偏移量。所以,它的意思就是:从0开始,每1个时间单位执行一次。

那么,时间单位又是什么呢?

Cron表达式中的五个部分分别代表:分钟、小时、日、月、星期。

所以,上面这句Cron表达式的意思是:从当前开始,每分钟执行一次。

而这里要执行的内容,就是jobTemplate定义的Job了。

所以,这个CronJob对象在创建1分钟后,就会有一个Job产生了,如下所示:

$ kubectl create -f ./cronjob.yaml
cronjob "hello" created

# 一分钟后
$ kubectl get jobs
NAME               DESIRED   SUCCESSFUL   AGE
hello-4111706356   1         1         2s

此时,CronJob对象会记录下这次Job执行的时间:

$ kubectl get cronjob hello
NAME      SCHEDULE      SUSPEND   ACTIVE    LAST-SCHEDULE
hello     */1 * * * *   False     0         Thu, 6 Sep 2018 14:34:00 -070

需要注意的是,由于定时任务的特殊性,很可能某个Job还没有执行完,另外一个新Job就产生了。这时候,你可以通过spec.concurrencyPolicy字段来定义具体的处理策略。比如:

  1. concurrencyPolicy=Allow,这也是默认情况,这意味着这些Job可以同时存在;

  2. concurrencyPolicy=Forbid,这意味着不会创建新的Pod,该创建周期被跳过;

  3. concurrencyPolicy=Replace,这意味着新产生的Job会替换旧的、没有执行完的Job。

而如果某一次Job创建失败,这次创建就会被标记为“miss”。当在指定的时间窗口内,miss的数目达到100时,那么CronJob会停止再创建这个Job。

这个时间窗口,可以由spec.startingDeadlineSeconds字段指定。比如startingDeadlineSeconds=200,意味着在过去200 s里,如果miss的数目达到了100次,那么这个Job就不会被创建执行了。

总结

在今天这篇文章中,我主要和你分享了Job这个离线业务的编排方法,讲解了completions和parallelism字段的含义,以及Job Controller的执行原理。

紧接着,我通过实例和你分享了Job对象三种常见的使用方法。但是,根据我在社区和生产环境中的经验,大多数情况下用户还是更倾向于自己控制Job对象。所以,相比于这些固定的“模式”,掌握Job的API对象,和它各个字段的准确含义会更加重要。

最后,我还介绍了一种Job的控制器,叫作:CronJob。这也印证了我在前面的分享中所说的:用一个对象控制另一个对象,是Kubernetes编排的精髓所在。

思考题

根据Job控制器的工作原理,如果你定义的parallelism比completions还大的话,比如:

 parallelism: 4
 completions: 2

那么,这个Job最开始创建的时候,会同时启动几个Pod呢?原因是什么?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

23-声明式API与Kubernetes编程范式

你好,我是张磊。今天我和你分享的主题是:声明式API与Kubernetes编程范式。

在前面的几篇文章中,我和你分享了很多Kubernetes的API对象。这些API对象,有的是用来描述应用,有的则是为应用提供各种各样的服务。但是,无一例外地,为了使用这些API对象提供的能力,你都需要编写一个对应的YAML文件交给Kubernetes。

这个YAML文件,正是Kubernetes声明式API所必须具备的一个要素。不过,是不是只要用YAML文件代替了命令行操作,就是声明式API了呢?

举个例子。我们知道,Docker Swarm的编排操作都是基于命令行的,比如:

$ docker service create --name nginx --replicas 2  nginx
$ docker service update --image nginx:1.7.9 nginx

像这样的两条命令,就是用Docker Swarm启动了两个Nginx容器实例。其中,第一条create命令创建了这两个容器,而第二条update命令则把它们“滚动更新”成了一个新的镜像。

对于这种使用方式,我们称为命令式命令行操作

那么,像上面这样的创建和更新两个Nginx容器的操作,在Kubernetes里又该怎么做呢?

这个流程,相信你已经非常熟悉了:我们需要在本地编写一个Deployment的YAML文件:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
spec:
  selector:
    matchLabels:
      app: nginx
  replicas: 2
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx
        ports:
        - containerPort: 80

然后,我们还需要使用kubectl create命令在Kubernetes里创建这个Deployment对象:

$ kubectl create -f nginx.yaml

这样,两个Nginx的Pod就会运行起来了。

而如果要更新这两个Pod使用的Nginx镜像,该怎么办呢?

我们前面曾经使用过kubectl set image和kubectl edit命令,来直接修改Kubernetes里的API对象。不过,相信很多人都有这样的想法,我能不能通过修改本地YAML文件来完成这个操作呢?这样我的改动就会体现在这个本地YAML文件里了。

当然可以。

比如,我们可以修改这个YAML文件里的Pod模板部分,把Nginx容器的镜像改成1.7.9,如下所示:

...
    spec:
      containers:
      - name: nginx
        image: nginx:1.7.9

而接下来,我们就可以执行一句kubectl replace操作,来完成这个Deployment的更新:

$ kubectl replace -f nginx.yaml

可是,上面这种基于YAML文件的操作方式,是“声明式API”吗?

并不是。

对于上面这种先kubectl create,再replace的操作,我们称为命令式配置文件操作。

也就是说,它的处理方式,其实跟前面Docker Swarm的两句命令,没什么本质上的区别。只不过,它是把Docker命令行里的参数,写在了配置文件里而已。

那么,到底什么才是“声明式API”呢?

答案是,kubectl apply命令。

在前面的文章中,我曾经提到过这个kubectl apply命令,并推荐你使用它来代替kubectl create命令(你也可以借此机会再回顾一下第12篇文章《牛刀小试:我的第一个容器化应用》中的相关内容)。

现在,我就使用kubectl apply命令来创建这个Deployment:

$ kubectl apply -f nginx.yaml

这样,Nginx的Deployment就被创建了出来,这看起来跟kubectl create的效果一样。

然后,我再修改一下nginx.yaml里定义的镜像:

...
    spec:
      containers:
      - name: nginx
        image: nginx:1.7.9

这时候,关键来了。

在修改完这个YAML文件之后,我不再使用kubectl replace命令进行更新,而是继续执行一条kubectl apply命令,即:

$ kubectl apply -f nginx.yaml

这时,Kubernetes就会立即触发这个Deployment的“滚动更新”。

可是,它跟kubectl replace命令有什么本质区别吗?

实际上,你可以简单地理解为,kubectl replace的执行过程,是使用新的YAML文件中的API对象,替换原有的API对象;而kubectl apply,则是执行了一个对原有API对象的PATCH操作

类似地,kubectl set image和kubectl edit也是对已有API对象的修改。

更进一步地,这意味着kube-apiserver在响应命令式请求(比如,kubectl replace)的时候,一次只能处理一个写请求,否则会有产生冲突的可能。而对于声明式请求(比如,kubectl apply),一次能处理多个写操作,并且具备Merge能力

这种区别,可能乍一听起来没那么重要。而且,正是由于要照顾到这样的API设计,做同样一件事情,Kubernetes需要的步骤往往要比其他项目多不少。

但是,如果你仔细思考一下Kubernetes项目的工作流程,就不难体会到这种声明式API的独到之处。

接下来,我就以Istio项目为例,来为你讲解一下声明式API在实际使用时的重要意义。

在2017年5月,Google、IBM和Lyft公司,共同宣布了Istio开源项目的诞生。很快,这个项目就在技术圈儿里,掀起了一阵名叫“微服务”的热潮,把Service Mesh这个新的编排概念推到了风口浪尖。

而Istio项目,实际上就是一个基于Kubernetes项目的微服务治理框架。它的架构非常清晰,如下所示:


在上面这个架构图中,我们不难看到Istio项目架构的核心所在。Istio最根本的组件,是运行在每一个应用Pod里的Envoy容器

这个Envoy项目是Lyft公司推出的一个高性能C++网络代理,也是Lyft公司对Istio项目的唯一贡献。

而Istio项目,则把这个代理服务以sidecar容器的方式,运行在了每一个被治理的应用Pod中。我们知道,Pod里的所有容器都共享同一个Network Namespace。所以,Envoy容器就能够通过配置Pod里的iptables规则,把整个Pod的进出流量接管下来。

这时候,Istio的控制层(Control Plane)里的Pilot组件,就能够通过调用每个Envoy容器的API,对这个Envoy代理进行配置,从而实现微服务治理。

我们一起来看一个例子。

假设这个Istio架构图左边的Pod是已经在运行的应用,而右边的Pod则是我们刚刚上线的应用的新版本。这时候,Pilot通过调节这两Pod里的Envoy容器的配置,从而将90%的流量分配给旧版本的应用,将10%的流量分配给新版本应用,并且,还可以在后续的过程中随时调整。这样,一个典型的“灰度发布”的场景就完成了。比如,Istio可以调节这个流量从90%-10%,改到80%-20%,再到50%-50%,最后到0%-100%,就完成了这个灰度发布的过程。

更重要的是,在整个微服务治理的过程中,无论是对Envoy容器的部署,还是像上面这样对Envoy代理的配置,用户和应用都是完全“无感”的。

这时候,你可能会有所疑惑:Istio项目明明需要在每个Pod里安装一个Envoy容器,又怎么能做到“无感”的呢?

实际上,Istio项目使用的,是Kubernetes中的一个非常重要的功能,叫作Dynamic Admission Control。

在Kubernetes项目中,当一个Pod或者任何一个API对象被提交给APIServer之后,总有一些“初始化”性质的工作需要在它们被Kubernetes项目正式处理之前进行。比如,自动为所有Pod加上某些标签(Labels)。

而这个“初始化”操作的实现,借助的是一个叫作Admission的功能。它其实是Kubernetes项目里一组被称为Admission Controller的代码,可以选择性地被编译进APIServer中,在API对象创建之后会被立刻调用到。

但这就意味着,如果你现在想要添加一些自己的规则到Admission Controller,就会比较困难。因为,这要求重新编译并重启APIServer。显然,这种使用方法对Istio来说,影响太大了。

所以,Kubernetes项目为我们额外提供了一种“热插拔”式的Admission机制,它就是Dynamic Admission Control,也叫作:Initializer。

现在,我给你举个例子。比如,我有如下所示的一个应用Pod:

apiVersion: v1
kind: Pod
metadata:
  name: myapp-pod
  labels:
    app: myapp
spec:
  containers:
  - name: myapp-container
    image: busybox
    command: ['sh', '-c', 'echo Hello Kubernetes! && sleep 3600']

可以看到,这个Pod里面只有一个用户容器,叫作:myapp-container。

接下来,Istio项目要做的,就是在这个Pod YAML被提交给Kubernetes之后,在它对应的API对象里自动加上Envoy容器的配置,使这个对象变成如下所示的样子:

apiVersion: v1
kind: Pod
metadata:
  name: myapp-pod
  labels:
    app: myapp
spec:
  containers:
  - name: myapp-container
    image: busybox
    command: ['sh', '-c', 'echo Hello Kubernetes! && sleep 3600']
  - name: envoy
    image: lyft/envoy:845747b88f102c0fd262ab234308e9e22f693a1
    command: ["/usr/local/bin/envoy"]
    ...

可以看到,被Istio处理后的这个Pod里,除了用户自己定义的myapp-container容器之外,多出了一个叫作envoy的容器,它就是Istio要使用的Envoy代理。

那么,Istio又是如何在用户完全不知情的前提下完成这个操作的呢?

Istio要做的,就是编写一个用来为Pod“自动注入”Envoy容器的Initializer。

首先,Istio会将这个Envoy容器本身的定义,以ConfigMap的方式保存在Kubernetes当中。这个ConfigMap(名叫:envoy-initializer)的定义如下所示:

apiVersion: v1
kind: ConfigMap
metadata:
  name: envoy-initializer
data:
  config: |
    containers:
      - name: envoy
        image: lyft/envoy:845747db88f102c0fd262ab234308e9e22f693a1
        command: ["/usr/local/bin/envoy"]
        args:
          - "--concurrency 4"
          - "--config-path /etc/envoy/envoy.json"
          - "--mode serve"
        ports:
          - containerPort: 80
            protocol: TCP
        resources:
          limits:
            cpu: "1000m"
            memory: "512Mi"
          requests:
            cpu: "100m"
            memory: "64Mi"
        volumeMounts:
          - name: envoy-conf
            mountPath: /etc/envoy
    volumes:
      - name: envoy-conf
        configMap:
          name: envoy

相信你已经注意到了,这个ConfigMap的data部分,正是一个Pod对象的一部分定义。其中,我们可以看到Envoy容器对应的containers字段,以及一个用来声明Envoy配置文件的volumes字段。

不难想到,Initializer要做的工作,就是把这部分Envoy相关的字段,自动添加到用户提交的Pod的API对象里。可是,用户提交的Pod里本来就有containers字段和volumes字段,所以Kubernetes在处理这样的更新请求时,就必须使用类似于git merge这样的操作,才能将这两部分内容合并在一起。

所以说,在Initializer更新用户的Pod对象的时候,必须使用PATCH API来完成。而这种PATCH API,正是声明式API最主要的能力。

接下来,Istio将一个编写好的Initializer,作为一个Pod部署在Kubernetes中。这个Pod的定义非常简单,如下所示:

apiVersion: v1
kind: Pod
metadata:
  labels:
    app: envoy-initializer
  name: envoy-initializer
spec:
  containers:
    - name: envoy-initializer
      image: envoy-initializer:0.0.1
      imagePullPolicy: Always

我们可以看到,这个envoy-initializer使用的envoy-initializer:0.0.1镜像,就是一个事先编写好的“自定义控制器”(Custom Controller),我将会在下一篇文章中讲解它的编写方法。而在这里,我要先为你解释一下这个控制器的主要功能。

我曾在第16篇文章《编排其实很简单:谈谈“控制器”模型》中和你分享过,一个Kubernetes的控制器,实际上就是一个“死循环”:它不断地获取“实际状态”,然后与“期望状态”作对比,并以此为依据决定下一步的操作。

而Initializer的控制器,不断获取到的“实际状态”,就是用户新创建的Pod。而它的“期望状态”,则是:这个Pod里被添加了Envoy容器的定义。

我还是用一段Go语言风格的伪代码,来为你描述这个控制逻辑,如下所示:

for {
  // 获取新创建的Pod
  pod := client.GetLatestPod()
  // Diff一下,检查是否已经初始化过
  if !isInitialized(pod) {
    // 没有?那就来初始化一下
    doSomething(pod)
  }
}
  • 如果这个Pod里面已经添加过Envoy容器,那么就“放过”这个Pod,进入下一个检查周期。
  • 而如果还没有添加过Envoy容器的话,它就要进行Initialize操作了,即:修改该Pod的API对象(doSomething函数)。

这时候,你应该立刻能想到,Istio要往这个Pod里合并的字段,正是我们之前保存在envoy-initializer这个ConfigMap里的数据(即:它的data字段的值)。

所以,在Initializer控制器的工作逻辑里,它首先会从APIServer中拿到这个ConfigMap:

func doSomething(pod) {
  cm := client.Get(ConfigMap, "envoy-initializer")
}

然后,把这个ConfigMap里存储的containers和volumes字段,直接添加进一个空的Pod对象里:

func doSomething(pod) {
  cm := client.Get(ConfigMap, "envoy-initializer")
  
  newPod := Pod{}
  newPod.Spec.Containers = cm.Containers
  newPod.Spec.Volumes = cm.Volumes
}

现在,关键来了。

Kubernetes的API库,为我们提供了一个方法,使得我们可以直接使用新旧两个Pod对象,生成一个TwoWayMergePatch:

func doSomething(pod) {
  cm := client.Get(ConfigMap, "envoy-initializer")

  newPod := Pod{}
  newPod.Spec.Containers = cm.Containers
  newPod.Spec.Volumes = cm.Volumes

  // 生成patch数据
  patchBytes := strategicpatch.CreateTwoWayMergePatch(pod, newPod)

  // 发起PATCH请求,修改这个pod对象
  client.Patch(pod.Name, patchBytes)
}

有了这个TwoWayMergePatch之后,Initializer的代码就可以使用这个patch的数据,调用Kubernetes的Client,发起一个PATCH请求

这样,一个用户提交的Pod对象里,就会被自动加上Envoy容器相关的字段。

当然,Kubernetes还允许你通过配置,来指定要对什么样的资源进行这个Initialize操作,比如下面这个例子:

apiVersion: admissionregistration.k8s.io/v1alpha1
kind: InitializerConfiguration
metadata:
  name: envoy-config
initializers:
  // 这个名字必须至少包括两个 "."
  - name: envoy.initializer.kubernetes.io
    rules:
      - apiGroups:
          - "" // 前面说过, ""就是core API Group的意思
        apiVersions:
          - v1
        resources:
          - pods

这个配置,就意味着Kubernetes要对所有的Pod进行这个Initialize操作,并且,我们指定了负责这个操作的Initializer,名叫:envoy-initializer。

而一旦这个InitializerConfiguration被创建,Kubernetes就会把这个Initializer的名字,加在所有新创建的Pod的Metadata上,格式如下所示:

apiVersion: v1
kind: Pod
metadata:
  initializers:
    pending:
      - name: envoy.initializer.kubernetes.io
  name: myapp-pod
  labels:
    app: myapp
...

可以看到,每一个新创建的Pod,都会自动携带了metadata.initializers.pending的Metadata信息。

这个Metadata,正是接下来Initializer的控制器判断这个Pod有没有执行过自己所负责的初始化操作的重要依据(也就是前面伪代码中isInitialized()方法的含义)。

这也就意味着,当你在Initializer里完成了要做的操作后,一定要记得将这个metadata.initializers.pending标志清除掉。这一点,你在编写Initializer代码的时候一定要非常注意。

此外,除了上面的配置方法,你还可以在具体的Pod的Annotation里添加一个如下所示的字段,从而声明要使用某个Initializer:

apiVersion: v1
kind: Pod
metadata
  annotations:
    "initializer.kubernetes.io/envoy": "true"
    ...

在这个Pod里,我们添加了一个Annotation,写明: initializer.kubernetes.io/envoy=true。这样,就会使用到我们前面所定义的envoy-initializer了。

以上,就是关于Initializer最基本的工作原理和使用方法了。相信你此时已经明白,Istio项目的核心,就是由无数个运行在应用Pod中的Envoy容器组成的服务代理网格。这也正是Service Mesh的含义。

备注:如果你对这个Demo感兴趣,可以在这个GitHub链接里找到它的所有源码和文档。这个Demo,是我fork自Kelsey Hightower的一个同名的Demo。

而这个机制得以实现的原理,正是借助了Kubernetes能够对API对象进行在线更新的能力,这也正是Kubernetes“声明式API”的独特之处:

  • 首先,所谓“声明式”,指的就是我只需要提交一个定义好的API对象来“声明”,我所期望的状态是什么样子。
  • 其次,“声明式API”允许有多个API写端,以PATCH的方式对API对象进行修改,而无需关心本地原始YAML文件的内容。
  • 最后,也是最重要的,有了上述两个能力,Kubernetes项目才可以基于对API对象的增、删、改、查,在完全无需外界干预的情况下,完成对“实际状态”和“期望状态”的调谐(Reconcile)过程。

所以说,声明式API,才是Kubernetes项目编排能力“赖以生存”的核心所在,希望你能够认真理解。

此外,不难看到,无论是对sidecar容器的巧妙设计,还是对Initializer的合理利用,Istio项目的设计与实现,其实都依托于Kubernetes的声明式API和它所提供的各种编排能力。可以说,Istio是在Kubernetes项目使用上的一位“集大成者”。

要知道,一个Istio项目部署完成后,会在Kubernetes里创建大约43个API对象。

所以,Kubernetes社区也看得很明白:Istio项目有多火热,就说明Kubernetes这套“声明式API”有多成功。这,既是Google Cloud喜闻乐见的事情,也是Istio项目一推出就被Google公司和整个技术圈儿热捧的重要原因。

而在使用Initializer的流程中,最核心的步骤,莫过于Initializer“自定义控制器”的编写过程。它遵循的,正是标准的“Kubernetes编程范式”,即:

如何使用控制器模式,同Kubernetes里API对象的“增、删、改、查”进行协作,进而完成用户业务逻辑的编写过程。

这,也正是我要在后面文章中为你详细讲解的内容。

总结

在今天这篇文章中,我为你重点讲解了Kubernetes声明式API的含义。并且,通过对Istio项目的剖析,我为你说明了它使用Kubernetes的Initializer特性,完成Envoy容器“自动注入”的原理。

事实上,从“使用Kubernetes部署代码”,到“使用Kubernetes编写代码”的蜕变过程,正是你从一个Kubernetes用户,到Kubernetes玩家的晋级之路。

而,如何理解“Kubernetes编程范式”,如何为Kubernetes添加自定义API对象,编写自定义控制器,正是这个晋级过程中的关键点,也是我要在后面几篇文章中分享的核心内容。

此外,基于今天这篇文章所讲述的Istio的工作原理,尽管Istio项目一直宣称它可以运行在非Kubernetes环境中,但我并不建议你花太多时间去做这个尝试。

毕竟,无论是从技术实现还是在社区运作上,Istio与Kubernetes项目之间都是紧密的、唇齿相依的关系。如果脱离了Kubernetes项目这个基础,那么这条原本就不算平坦的“微服务”之路,恐怕会更加困难重重。

思考题

你是否对Envoy项目做过了解?你觉得为什么它能够击败Nginx以及HAProxy等竞品,成为Service Mesh体系的核心?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

24-深入解析声明式API(一):API对象的奥秘

你好,我是张磊。今天我和你分享的主题是:深入解析声明式API之API对象的奥秘。

在上一篇文章中,我为你详细讲解了Kubernetes声明式API的设计、特点,以及使用方式。

而在今天这篇文章中,我就来为你讲解一下Kubernetes声明式API的工作原理,以及如何利用这套API机制,在Kubernetes里添加自定义的API对象。

你可能一直就很好奇:当我把一个YAML文件提交给Kubernetes之后,它究竟是如何创建出一个API对象的呢?

这得从声明式API的设计谈起了。

在Kubernetes项目中,一个API对象在Etcd里的完整资源路径,是由:Group(API组)、Version(API版本)和Resource(API资源类型)三个部分组成的。

通过这样的结构,整个Kubernetes里的所有API对象,实际上就可以用如下的树形结构表示出来:


在这幅图中,你可以很清楚地看到Kubernetes里API对象的组织方式,其实是层层递进的。

比如,现在我要声明要创建一个CronJob对象,那么我的YAML文件的开始部分会这么写:

apiVersion: batch/v2alpha1
kind: CronJob
...

在这个YAML文件中,“CronJob”就是这个API对象的资源类型(Resource),“batch”就是它的组(Group),v2alpha1就是它的版本(Version)。

当我们提交了这个YAML文件之后,Kubernetes就会把这个YAML文件里描述的内容,转换成Kubernetes里的一个CronJob对象。

那么,Kubernetes是如何对Resource、Group和Version进行解析,从而在Kubernetes项目里找到CronJob对象的定义呢?

首先,Kubernetes会匹配API对象的组。

需要明确的是,对于Kubernetes里的核心API对象,比如:Pod、Node等,是不需要Group的(即:它们的Group是“”)。所以,对于这些API对象来说,Kubernetes会直接在/api这个层级进行下一步的匹配过程。

而对于CronJob等非核心API对象来说,Kubernetes就必须在/apis这个层级里查找它对应的Group,进而根据“batch”这个Group的名字,找到/apis/batch。

不难发现,这些API Group的分类是以对象功能为依据的,比如Job和CronJob就都属于“batch” (离线业务)这个Group。

然后,Kubernetes会进一步匹配到API对象的版本号。

对于CronJob这个API对象来说,Kubernetes在batch这个Group下,匹配到的版本号就是v2alpha1。

在Kubernetes中,同一种API对象可以有多个版本,这正是Kubernetes进行API版本化管理的重要手段。这样,比如在CronJob的开发过程中,对于会影响到用户的变更就可以通过升级新版本来处理,从而保证了向后兼容。

最后,Kubernetes会匹配API对象的资源类型。

在前面匹配到正确的版本之后,Kubernetes就知道,我要创建的原来是一个/apis/batch/v2alpha1下的CronJob对象。

这时候,APIServer就可以继续创建这个CronJob对象了。为了方便理解,我为你总结了一个如下所示流程图来阐述这个创建过程:


首先,当我们发起了创建CronJob的POST请求之后,我们编写的YAML的信息就被提交给了APIServer。

而APIServer的第一个功能,就是过滤这个请求,并完成一些前置性的工作,比如授权、超时处理、审计等。

然后,请求会进入MUX和Routes流程。如果你编写过Web Server的话就会知道,MUX和Routes是APIServer完成URL和Handler绑定的场所。而APIServer的Handler要做的事情,就是按照我刚刚介绍的匹配过程,找到对应的CronJob类型定义。

接着,APIServer最重要的职责就来了:根据这个CronJob类型定义,使用用户提交的YAML文件里的字段,创建一个CronJob对象。

而在这个过程中,APIServer会进行一个Convert工作,即:把用户提交的YAML文件,转换成一个叫作Super Version的对象,它正是该API资源类型所有版本的字段全集。这样用户提交的不同版本的YAML文件,就都可以用这个Super Version对象来进行处理了。

接下来,APIServer会先后进行Admission()和Validation()操作。比如,我在上一篇文章中提到的Admission Controller和Initializer,就都属于Admission的内容。

而Validation,则负责验证这个对象里的各个字段是否合法。这个被验证过的API对象,都保存在了APIServer里一个叫作Registry的数据结构中。也就是说,只要一个API对象的定义能在Registry里查到,它就是一个有效的Kubernetes API对象。

最后,APIServer会把验证过的API对象转换成用户最初提交的版本,进行序列化操作,并调用Etcd的API把它保存起来。

由此可见,声明式API对于Kubernetes来说非常重要。所以,APIServer这样一个在其他项目里“平淡无奇”的组件,却成了Kubernetes项目的重中之重。它不仅是Google Borg设计思想的集中体现,也是Kubernetes项目里唯一一个被Google公司和RedHat公司双重控制、其他势力根本无法参与其中的组件。

此外,由于同时要兼顾性能、API完备性、版本化、向后兼容等很多工程化指标,所以Kubernetes团队在APIServer项目里大量使用了Go语言的代码生成功能,来自动化诸如Convert、DeepCopy等与API资源相关的操作。这部分自动生成的代码,曾一度占到Kubernetes项目总代码的20%~30%。

这也是为何,在过去很长一段时间里,在这样一个极其“复杂”的APIServer中,添加一个Kubernetes风格的API资源类型,是一个非常困难的工作。

不过,在Kubernetes v1.7 之后,这个工作就变得轻松得多了。这,当然得益于一个全新的API插件机制:CRD。

CRD的全称是Custom Resource Definition。顾名思义,它指的就是,允许用户在Kubernetes中添加一个跟Pod、Node类似的、新的API资源类型,即:自定义API资源。

举个例子,我现在要为Kubernetes添加一个名叫Network的API资源类型

它的作用是,一旦用户创建一个Network对象,那么Kubernetes就应该使用这个对象定义的网络参数,调用真实的网络插件,比如Neutron项目,为用户创建一个真正的“网络”。这样,将来用户创建的Pod,就可以声明使用这个“网络”了。

这个Network对象的YAML文件,名叫example-network.yaml,它的内容如下所示:

apiVersion: samplecrd.k8s.io/v1
kind: Network
metadata:
  name: example-network
spec:
  cidr: "192.168.0.0/16"
  gateway: "192.168.0.1"

可以看到,我想要描述“网络”的API资源类型是Network;API组是samplecrd.k8s.io;API 版本是v1。

那么,Kubernetes又该如何知道这个API(samplecrd.k8s.io/v1/network)的存在呢?

其实,上面的这个YAML文件,就是一个具体的“自定义API资源”实例,也叫CR(Custom Resource)。而为了能够让Kubernetes认识这个CR,你就需要让Kubernetes明白这个CR的宏观定义是什么,也就是CRD(Custom Resource Definition)。

这就好比,你想让计算机认识各种兔子的照片,就得先让计算机明白,兔子的普遍定义是什么。比如,兔子“是哺乳动物”“有长耳朵,三瓣嘴”。

所以,接下来,我就先编写一个CRD的YAML文件,它的名字叫作network.yaml,内容如下所示:

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
  name: networks.samplecrd.k8s.io
spec:
  group: samplecrd.k8s.io
  version: v1
  names:
    kind: Network
    plural: networks
  scope: Namespaced

可以看到,在这个CRD中,我指定了“group: samplecrd.k8s.io”“version: v1”这样的API信息,也指定了这个CR的资源类型叫作Network,复数(plural)是networks。

然后,我还声明了它的scope是Namespaced,即:我们定义的这个Network是一个属于Namespace的对象,类似于Pod。

这就是一个Network API资源类型的API部分的宏观定义了。这就等同于告诉了计算机:“兔子是哺乳动物”。所以这时候,Kubernetes就能够认识和处理所有声明了API类型是“samplecrd.k8s.io/v1/network”的YAML文件了。

接下来,我还需要让Kubernetes“认识”这种YAML文件里描述的“网络”部分,比如“cidr”(网段),“gateway”(网关)这些字段的含义。这就相当于我要告诉计算机:“兔子有长耳朵和三瓣嘴”。

这时候呢,我就需要稍微做些代码工作了。

首先,我要在GOPATH下,创建一个结构如下的项目:

备注:在这里,我并不要求你具有完备的Go语言知识体系,但我会假设你已经了解了Golang的一些基本知识(比如,知道什么是GOPATH)。而如果你还不了解的话,可以在涉及到相关内容时,再去查阅一些相关资料。

$ tree $GOPATH/src/github.com/<your-name>/k8s-controller-custom-resource
.
├── controller.go
├── crd
│   └── network.yaml
├── example
│   └── example-network.yaml
├── main.go
└── pkg
    └── apis
        └── samplecrd
            ├── register.go
            └── v1
                ├── doc.go
                ├── register.go
                └── types.go

其中,pkg/apis/samplecrd就是API组的名字,v1是版本,而v1下面的types.go文件里,则定义了Network对象的完整描述。我已经把这个项目上传到了GitHub上,你可以随时参考。

然后,我在pkg/apis/samplecrd目录下创建了一个register.go文件,用来放置后面要用到的全局变量。这个文件的内容如下所示:

package samplecrd

const (
 GroupName = "samplecrd.k8s.io"
 Version   = "v1"
)

接着,我需要在pkg/apis/samplecrd目录下添加一个doc.go文件(Golang的文档源文件)。这个文件里的内容如下所示:

// +k8s:deepcopy-gen=package

// +groupName=samplecrd.k8s.io
package v1

在这个文件中,你会看到+<tag_name>[=value]格式的注释,这就是Kubernetes进行代码生成要用的Annotation风格的注释。

其中,+k8s:deepcopy-gen=package意思是,请为整个v1包里的所有类型定义自动生成DeepCopy方法;而+groupName=samplecrd.k8s.io,则定义了这个包对应的API组的名字。

可以看到,这些定义在doc.go文件的注释,起到的是全局的代码生成控制的作用,所以也被称为Global Tags。

接下来,我需要添加types.go文件。顾名思义,它的作用就是定义一个Network类型到底有哪些字段(比如,spec字段里的内容)。这个文件的主要内容如下所示:

package v1
...
// +genclient
// +genclient:noStatus
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object

// Network describes a Network resource
type Network struct {
 // TypeMeta is the metadata for the resource, like kind and apiversion
 metav1.TypeMeta `json:",inline"`
 // ObjectMeta contains the metadata for the particular object, including
 // things like...
 //  - name
 //  - namespace
 //  - self link
 //  - labels
 //  - ... etc ...
 metav1.ObjectMeta `json:"metadata,omitempty"`
 
 Spec networkspec `json:"spec"`
}
// networkspec is the spec for a Network resource
type networkspec struct {
 Cidr    string `json:"cidr"`
 Gateway string `json:"gateway"`
}

// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object

// NetworkList is a list of Network resources
type NetworkList struct {
 metav1.TypeMeta `json:",inline"`
 metav1.ListMeta `json:"metadata"`
 
 Items []Network `json:"items"`
}

在上面这部分代码里,你可以看到Network类型定义方法跟标准的Kubernetes对象一样,都包括了TypeMeta(API元数据)和ObjectMeta(对象元数据)字段。

而其中的Spec字段,就是需要我们自己定义的部分。所以,在networkspec里,我定义了Cidr和Gateway两个字段。其中,每个字段最后面的部分比如json:"cidr",指的就是这个字段被转换成JSON格式之后的名字,也就是YAML文件里的字段名字。

如果你不熟悉这个用法的话,可以查阅一下Golang的文档。

此外,除了定义Network类型,你还需要定义一个NetworkList类型,用来描述一组Network对象应该包括哪些字段。之所以需要这样一个类型,是因为在Kubernetes中,获取所有X对象的List()方法,返回值都是List类型,而不是X类型的数组。这是不一样的。

同样地,在Network和NetworkList类型上,也有代码生成注释。

其中,+genclient的意思是:请为下面这个API资源类型生成对应的Client代码(这个Client,我马上会讲到)。而+genclient:noStatus的意思是:这个API资源类型定义里,没有Status字段。否则,生成的Client就会自动带上UpdateStatus方法。

如果你的类型定义包括了Status字段的话,就不需要这句+genclient:noStatus注释了。比如下面这个例子:

// +genclient

// Network is a specification for a Network resource
type Network struct {
 metav1.TypeMeta   `json:",inline"`
 metav1.ObjectMeta `json:"metadata,omitempty"`
 
 Spec   NetworkSpec   `json:"spec"`
 Status NetworkStatus `json:"status"`
}

需要注意的是,+genclient只需要写在Network类型上,而不用写在NetworkList上。因为NetworkList只是一个返回值类型,Network才是“主类型”。

而由于我在Global Tags里已经定义了为所有类型生成DeepCopy方法,所以这里就不需要再显式地加上+k8s:deepcopy-gen=true了。当然,这也就意味着你可以用+k8s:deepcopy-gen=false来阻止为某些类型生成DeepCopy。

你可能已经注意到,在这两个类型上面还有一句+k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object的注释。它的意思是,请在生成DeepCopy的时候,实现Kubernetes提供的runtime.Object接口。否则,在某些版本的Kubernetes里,你的这个类型定义会出现编译错误。这是一个固定的操作,记住即可。

不过,你或许会有这样的顾虑:这些代码生成注释这么灵活,我该怎么掌握呢?

其实,上面我所讲述的内容,已经足以应对99%的场景了。当然,如果你对代码生成感兴趣的话,我推荐你阅读这篇博客,它详细地介绍了Kubernetes的代码生成语法。

最后,我需要再编写一个pkg/apis/samplecrd/v1/register.go文件

在前面对APIServer工作原理的讲解中,我已经提到,“registry”的作用就是注册一个类型(Type)给APIServer。其中,Network资源类型在服务器端注册的工作,APIServer会自动帮我们完成。但与之对应的,我们还需要让客户端也能“知道”Network资源类型的定义。这就需要我们在项目里添加一个register.go文件。它最主要的功能,就是定义了如下所示的addKnownTypes()方法:

package v1
...
// addKnownTypes adds our types to the API scheme by registering
// Network and NetworkList
func addKnownTypes(scheme *runtime.Scheme) error {
 scheme.AddKnownTypes(
  SchemeGroupVersion,
  &Network{},
  &NetworkList{},
 )
 
 // register the type in the scheme
 metav1.AddToGroupVersion(scheme, SchemeGroupVersion)
 return nil
}

有了这个方法,Kubernetes就能够在后面生成客户端的时候,“知道”Network以及NetworkList类型的定义了。

像上面这种register.go文件里的内容其实是非常固定的,你以后可以直接使用我提供的这部分代码做模板,然后把其中的资源类型、GroupName和Version替换成你自己的定义即可。

这样,Network对象的定义工作就全部完成了。可以看到,它其实定义了两部分内容:

  • 第一部分是,自定义资源类型的API描述,包括:组(Group)、版本(Version)、资源类型(Resource)等。这相当于告诉了计算机:兔子是哺乳动物。
  • 第二部分是,自定义资源类型的对象描述,包括:Spec、Status等。这相当于告诉了计算机:兔子有长耳朵和三瓣嘴。

接下来,我就要使用Kubernetes提供的代码生成工具,为上面定义的Network资源类型自动生成clientset、informer和lister。其中,clientset就是操作Network对象所需要使用的客户端,而informer和lister这两个包的主要功能,我会在下一篇文章中重点讲解。

这个代码生成工具名叫k8s.io/code-generator,使用方法如下所示:

# 代码生成的工作目录,也就是我们的项目路径
$ ROOT_PACKAGE="github.com/resouer/k8s-controller-custom-resource"
# API Group
$ CUSTOM_RESOURCE_NAME="samplecrd"
# API Version
$ CUSTOM_RESOURCE_VERSION="v1"

# 安装k8s.io/code-generator
$ go get -u k8s.io/code-generator/...
$ cd $GOPATH/src/k8s.io/code-generator

# 执行代码自动生成,其中pkg/client是生成目标目录,pkg/apis是类型定义目录
$ ./generate-groups.sh all "$ROOT_PACKAGE/pkg/client" "$ROOT_PACKAGE/pkg/apis" "$CUSTOM_RESOURCE_NAME:$CUSTOM_RESOURCE_VERSION"

代码生成工作完成之后,我们再查看一下这个项目的目录结构:

$ tree
.
├── controller.go
├── crd
│   └── network.yaml
├── example
│   └── example-network.yaml
├── main.go
└── pkg
    ├── apis
    │   └── samplecrd
    │       ├── constants.go
    │       └── v1
    │           ├── doc.go
    │           ├── register.go
    │           ├── types.go
    │           └── zz_generated.deepcopy.go
    └── client
        ├── clientset
        ├── informers
        └── listers

其中,pkg/apis/samplecrd/v1下面的zz_generated.deepcopy.go文件,就是自动生成的DeepCopy代码文件。

而整个client目录,以及下面的三个包(clientset、informers、 listers),都是Kubernetes为Network类型生成的客户端库,这些库会在后面编写自定义控制器的时候用到。

可以看到,到目前为止的这些工作,其实并不要求你写多少代码,主要考验的是“复制、粘贴、替换”这样的“基本功”。

而有了这些内容,现在你就可以在Kubernetes集群里创建一个Network类型的API对象了。我们不妨一起来试验下。

首先,使用network.yaml文件,在Kubernetes中创建Network对象的CRD(Custom Resource Definition):

$ kubectl apply -f crd/network.yaml
customresourcedefinition.apiextensions.k8s.io/networks.samplecrd.k8s.io created

这个操作,就告诉了Kubernetes,我现在要添加一个自定义的API对象。而这个对象的API信息,正是network.yaml里定义的内容。我们可以通过kubectl get命令,查看这个CRD:

$ kubectl get crd
NAME                        CREATED AT
networks.samplecrd.k8s.io   2018-09-15T10:57:12Z

然后,我们就可以创建一个Network对象了,这里用到的是example-network.yaml:

$ kubectl apply -f example/example-network.yaml
network.samplecrd.k8s.io/example-network created

通过这个操作,你就在Kubernetes集群里创建了一个Network对象。它的API资源路径是samplecrd.k8s.io/v1/networks

这时候,你就可以通过kubectl get命令,查看到新创建的Network对象:

$ kubectl get network
NAME              AGE
example-network   8s

你还可以通过kubectl describe命令,看到这个Network对象的细节:

$ kubectl describe network example-network
Name:         example-network
Namespace:    default
Labels:       <none>
...API Version:  samplecrd.k8s.io/v1
Kind:         Network
Metadata:
  ...
  Generation:          1
  Resource Version:    468239
  ...
Spec:
  Cidr:     192.168.0.0/16
  Gateway:  192.168.0.1

当然 ,你也可以编写更多的YAML文件来创建更多的Network对象,这和创建Pod、Deployment的操作,没有任何区别。

总结

在今天这篇文章中,我为你详细解析了Kubernetes声明式API的工作原理,讲解了如何遵循声明式API的设计,为Kubernetes添加一个名叫Network的API资源类型。从而达到了通过标准的kubectl create和get操作,来管理自定义API对象的目的。

不过,创建出这样一个自定义API对象,我们只是完成了Kubernetes声明式API的一半工作。

接下来的另一半工作是:为这个API对象编写一个自定义控制器(Custom Controller)。这样, Kubernetes才能根据Network API对象的“增、删、改”操作,在真实环境中做出相应的响应。比如,“创建、删除、修改”真正的Neutron网络。

而这,正是Network这个API对象所关注的“业务逻辑”。

这个业务逻辑的实现过程,以及它所使用的Kubernetes API编程库的工作原理,就是我要在下一篇文章中讲解的主要内容。

思考题

在了解了CRD的定义方法之后,你是否已经在考虑使用CRD(或者已经使用了CRD)来描述现实中的某种实体了呢?能否分享一下你的思路?(举个例子:某技术团队使用CRD描述了“宿主机”,然后用Kubernetes部署了Kubernetes)

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

25-深入解析声明式API(二):编写自定义控制器

你好,我是张磊。今天我和你分享的主题是:深入解析声明式API之编写自定义控制器。

在上一篇文章中,我和你详细分享了Kubernetes中声明式API的实现原理,并且通过一个添加Network对象的实例,为你讲述了在Kubernetes里添加API资源的过程。

在今天的这篇文章中,我就继续和你一起完成剩下一半的工作,即:为Network这个自定义API对象编写一个自定义控制器(Custom Controller)。

正如我在上一篇文章结尾处提到的,“声明式API”并不像“命令式API”那样有着明显的执行逻辑。这就使得基于声明式API的业务功能实现,往往需要通过控制器模式来“监视”API对象的变化(比如,创建或者删除Network),然后以此来决定实际要执行的具体工作。

接下来,我就和你一起通过编写代码来实现这个过程。这个项目和上一篇文章里的代码是同一个项目,你可以从这个GitHub库里找到它们。我在代码里还加上了丰富的注释,你可以随时参考。

总得来说,编写自定义控制器代码的过程包括:编写main函数、编写自定义控制器的定义,以及编写控制器里的业务逻辑三个部分。

首先,我们来编写这个自定义控制器的main函数。

main函数的主要工作就是,定义并初始化一个自定义控制器(Custom Controller),然后启动它。这部分代码的主要内容如下所示:

func main() {
  ...
  
  cfg, err := clientcmd.BuildConfigFromFlags(masterURL, kubeconfig)
  ...
  kubeClient, err := kubernetes.NewForConfig(cfg)
  ...
  networkClient, err := clientset.NewForConfig(cfg)
  ...
  
  networkInformerFactory := informers.NewSharedInformerFactory(networkClient, ...)
  
  controller := NewController(kubeClient, networkClient,
  networkInformerFactory.Samplecrd().V1().Networks())
  
  go networkInformerFactory.Start(stopCh)
 
  if err = controller.Run(2, stopCh); err != nil {
    glog.Fatalf("Error running controller: %s", err.Error())
  }
}

可以看到,这个main函数主要通过三步完成了初始化并启动一个自定义控制器的工作。

第一步:main函数根据我提供的Master配置(APIServer的地址端口和kubeconfig的路径),创建一个Kubernetes的client(kubeClient)和Network对象的client(networkClient)。

但是,如果我没有提供Master配置呢?

这时,main函数会直接使用一种名叫InClusterConfig的方式来创建这个client。这个方式,会假设你的自定义控制器是以Pod的方式运行在Kubernetes集群里的。

而我在第15篇文章《深入解析Pod对象(二):使用进阶》中曾经提到过,Kubernetes 里所有的Pod都会以Volume的方式自动挂载Kubernetes的默认ServiceAccount。所以,这个控制器就会直接使用默认ServiceAccount数据卷里的授权信息,来访问APIServer。

第二步:main函数为Network对象创建一个叫作InformerFactory(即:networkInformerFactory)的工厂,并使用它生成一个Network对象的Informer,传递给控制器。

第三步:main函数启动上述的Informer,然后执行controller.Run,启动自定义控制器。

至此,main函数就结束了。

看到这,你可能会感到非常困惑:编写自定义控制器的过程难道就这么简单吗?这个Informer又是个什么东西呢?

别着急。

接下来,我就为你详细解释一下这个自定义控制器的工作原理。

在Kubernetes项目中,一个自定义控制器的工作原理,可以用下面这样一幅流程图来表示(在后面的叙述中,我会用“示意图”来指代它):

图1 自定义控制器的工作流程示意图

我们先从这幅示意图的最左边看起。

这个控制器要做的第一件事,是从Kubernetes的APIServer里获取它所关心的对象,也就是我定义的Network对象

这个操作,依靠的是一个叫作Informer(可以翻译为:通知器)的代码库完成的。Informer与API对象是一一对应的,所以我传递给自定义控制器的,正是一个Network对象的Informer(Network Informer)。

不知你是否已经注意到,我在创建这个Informer工厂的时候,需要给它传递一个networkClient。

事实上,Network Informer正是使用这个networkClient,跟APIServer建立了连接。不过,真正负责维护这个连接的,则是Informer所使用的Reflector包。

更具体地说,Reflector使用的是一种叫作ListAndWatch的方法,来“获取”并“监听”这些Network对象实例的变化。

在ListAndWatch机制下,一旦APIServer端有新的Network实例被创建、删除或者更新,Reflector都会收到“事件通知”。这时,该事件及它对应的API对象这个组合,就被称为增量(Delta),它会被放进一个Delta FIFO Queue(即:增量先进先出队列)中。

而另一方面,Informe会不断地从这个Delta FIFO Queue里读取(Pop)增量。每拿到一个增量,Informer就会判断这个增量里的事件类型,然后创建或者更新本地对象的缓存。这个缓存,在Kubernetes里一般被叫作Store。

比如,如果事件类型是Added(添加对象),那么Informer就会通过一个叫作Indexer的库把这个增量里的API对象保存在本地缓存中,并为它创建索引。相反,如果增量的事件类型是Deleted(删除对象),那么Informer就会从本地缓存中删除这个对象。

这个同步本地缓存的工作,是Informer的第一个职责,也是它最重要的职责。

Informer的第二个职责,则是根据这些事件的类型,触发事先注册好的ResourceEventHandler。这些Handler,需要在创建控制器的时候注册给它对应的Informer。

接下来,我们就来编写这个控制器的定义,它的主要内容如下所示:

func NewController(
  kubeclientset kubernetes.Interface,
  networkclientset clientset.Interface,
  networkInformer informers.NetworkInformer) *Controller {
  ...
  controller := &Controller{
    kubeclientset:    kubeclientset,
    networkclientset: networkclientset,
    networksLister:   networkInformer.Lister(),
    networksSynced:   networkInformer.Informer().HasSynced,
    workqueue:        workqueue.NewNamedRateLimitingQueue(...,  "Networks"),
    ...
  }
    networkInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{
    AddFunc: controller.enqueueNetwork,
    UpdateFunc: func(old, new interface{}) {
      oldNetwork := old.(*samplecrdv1.Network)
      newNetwork := new.(*samplecrdv1.Network)
      if oldNetwork.ResourceVersion == newNetwork.ResourceVersion {
        return
      }
      controller.enqueueNetwork(new)
    },
    DeleteFunc: controller.enqueueNetworkForDelete,
 return controller
}

我前面在main函数里创建了两个client(kubeclientset和networkclientset),然后在这段代码里,使用这两个client和前面创建的Informer,初始化了自定义控制器。

值得注意的是,在这个自定义控制器里,我还设置了一个工作队列(work queue),它正是处于示意图中间位置的WorkQueue。这个工作队列的作用是,负责同步Informer和控制循环之间的数据。

实际上,Kubernetes项目为我们提供了很多个工作队列的实现,你可以根据需要选择合适的库直接使用。

然后,我为networkInformer注册了三个Handler(AddFunc、UpdateFunc和DeleteFunc),分别对应API对象的“添加”“更新”和“删除”事件。而具体的处理操作,都是将该事件对应的API对象加入到工作队列中。

需要注意的是,实际入队的并不是API对象本身,而是它们的Key,即:该API对象的<namespace>/<name>

而我们后面即将编写的控制循环,则会不断地从这个工作队列里拿到这些Key,然后开始执行真正的控制逻辑。

综合上面的讲述,你现在应该就能明白,所谓Informer,其实就是一个带有本地缓存和索引机制的、可以注册EventHandler的client。它是自定义控制器跟APIServer进行数据同步的重要组件。

更具体地说,Informer通过一种叫作ListAndWatch的方法,把APIServer中的API对象缓存在了本地,并负责更新和维护这个缓存。

其中,ListAndWatch方法的含义是:首先,通过APIServer的LIST API“获取”所有最新版本的API对象;然后,再通过WATCH API来“监听”所有这些API对象的变化。

而通过监听到的事件变化,Informer就可以实时地更新本地缓存,并且调用这些事件对应的EventHandler了。

此外,在这个过程中,每经过resyncPeriod指定的时间,Informer维护的本地缓存,都会使用最近一次LIST返回的结果强制更新一次,从而保证缓存的有效性。在Kubernetes中,这个缓存强制更新的操作就叫作:resync。

需要注意的是,这个定时resync操作,也会触发Informer注册的“更新”事件。但此时,这个“更新”事件对应的Network对象实际上并没有发生变化,即:新、旧两个Network对象的ResourceVersion是一样的。在这种情况下,Informer就不需要对这个更新事件再做进一步的处理了。

这也是为什么我在上面的UpdateFunc方法里,先判断了一下新、旧两个Network对象的版本(ResourceVersion)是否发生了变化,然后才开始进行的入队操作。

以上,就是Kubernetes中的Informer库的工作原理了。

接下来,我们就来到了示意图中最后面的控制循环(Control Loop)部分,也正是我在main函数最后调用controller.Run()启动的“控制循环”。它的主要内容如下所示:

func (c *Controller) Run(threadiness int, stopCh <-chan struct{}) error {
 ...
  if ok := cache.WaitForCacheSync(stopCh, c.networksSynced); !ok {
    return fmt.Errorf("failed to wait for caches to sync")
  }
  
  ...
  for i := 0; i < threadiness; i++ {
    go wait.Until(c.runWorker, time.Second, stopCh)
  }
  
  ...
  return nil
}

可以看到,启动控制循环的逻辑非常简单:

  • 首先,等待Informer完成一次本地缓存的数据同步操作;
  • 然后,直接通过goroutine启动一个(或者并发启动多个)“无限循环”的任务。

而这个“无限循环”任务的每一个循环周期,执行的正是我们真正关心的业务逻辑。

所以接下来,我们就来编写这个自定义控制器的业务逻辑,它的主要内容如下所示:

func (c *Controller) runWorker() {
  for c.processNextWorkItem() {
  }
}

func (c *Controller) processNextWorkItem() bool {
  obj, shutdown := c.workqueue.Get()
  
  ...
  
  err := func(obj interface{}) error {
    ...
    if err := c.syncHandler(key); err != nil {
     return fmt.Errorf("error syncing '%s': %s", key, err.Error())
    }
    
    c.workqueue.Forget(obj)
    ...
    return nil
  }(obj)
  
  ...
  
  return true
}

func (c *Controller) syncHandler(key string) error {

  namespace, name, err := cache.SplitMetaNamespaceKey(key)
  ...
  
  network, err := c.networksLister.Networks(namespace).Get(name)
  if err != nil {
    if errors.IsNotFound(err) {
      glog.Warningf("Network does not exist in local cache: %s/%s, will delete it from Neutron ...",
      namespace, name)
      
      glog.Warningf("Network: %s/%s does not exist in local cache, will delete it from Neutron ...",
    namespace, name)
    
     // FIX ME: call Neutron API to delete this network by name.
     //
     // neutron.Delete(namespace, name)
    
     return nil
  }
    ...
    
    return err
  }
  
  glog.Infof("[Neutron] Try to process network: %#v ...", network)
  
  // FIX ME: Do diff().
  //
  // actualNetwork, exists := neutron.Get(namespace, name)
  //
  // if !exists {
  //   neutron.Create(namespace, name)
  // } else if !reflect.DeepEqual(actualNetwork, network) {
  //   neutron.Update(namespace, name)
  // }
  
  return nil
}

可以看到,在这个执行周期里(processNextWorkItem),我们首先从工作队列里出队(workqueue.Get)了一个成员,也就是一个Key(Network对象的:namespace/name)。

然后,在syncHandler方法中,我使用这个Key,尝试从Informer维护的缓存中拿到了它所对应的Network对象。

可以看到,在这里,我使用了networksLister来尝试获取这个Key对应的Network对象。这个操作,其实就是在访问本地缓存的索引。实际上,在Kubernetes的源码中,你会经常看到控制器从各种Lister里获取对象,比如:podLister、nodeLister等等,它们使用的都是Informer和缓存机制。

而如果控制循环从缓存中拿不到这个对象(即:networkLister返回了IsNotFound错误),那就意味着这个Network对象的Key是通过前面的“删除”事件添加进工作队列的。所以,尽管队列里有这个Key,但是对应的Network对象已经被删除了。

这时候,我就需要调用Neutron的API,把这个Key对应的Neutron网络从真实的集群里删除掉。

而如果能够获取到对应的Network对象,我就可以执行控制器模式里的对比“期望状态”和“实际状态”的逻辑了。

其中,自定义控制器“千辛万苦”拿到的这个Network对象,正是APIServer里保存的“期望状态”,即:用户通过YAML文件提交到APIServer里的信息。当然,在我们的例子里,它已经被Informer缓存在了本地。

那么,“实际状态”又从哪里来呢?

当然是来自于实际的集群了。

所以,我们的控制循环需要通过Neutron API来查询实际的网络情况。

比如,我可以先通过Neutron来查询这个Network对象对应的真实网络是否存在。

  • 如果不存在,这就是一个典型的“期望状态”与“实际状态”不一致的情形。这时,我就需要使用这个Network对象里的信息(比如:CIDR和Gateway),调用Neutron API来创建真实的网络。
  • 如果存在,那么,我就要读取这个真实网络的信息,判断它是否跟Network对象里的信息一致,从而决定我是否要通过Neutron来更新这个已经存在的真实网络。

这样,我就通过对比“期望状态”和“实际状态”的差异,完成了一次调协(Reconcile)的过程。

至此,一个完整的自定义API对象和它所对应的自定义控制器,就编写完毕了。

备注:与Neutron相关的业务代码并不是本篇文章的重点,所以我仅仅通过注释里的伪代码为你表述了这部分内容。如果你对这些代码感兴趣的话,可以自行完成。最简单的情况,你可以自己编写一个Neutron Mock,然后输出对应的操作日志。

接下来,我们就一起来把这个项目运行起来,查看一下它的工作情况。

你可以自己编译这个项目,也可以直接使用我编译好的二进制文件(samplecrd-controller)。编译并启动这个项目的具体流程如下所示:

# Clone repo
$ git clone https://github.com/resouer/k8s-controller-custom-resource$ cd k8s-controller-custom-resource

### Skip this part if you don't want to build
# Install dependency
$ go get github.com/tools/godep
$ godep restore
# Build
$ go build -o samplecrd-controller .

$ ./samplecrd-controller -kubeconfig=$HOME/.kube/config -alsologtostderr=true
I0915 12:50:29.051349   27159 controller.go:84] Setting up event handlers
I0915 12:50:29.051615   27159 controller.go:113] Starting Network control loop
I0915 12:50:29.051630   27159 controller.go:116] Waiting for informer caches to sync
E0915 12:50:29.066745   27159 reflector.go:134] github.com/resouer/k8s-controller-custom-resource/pkg/client/informers/externalversions/factory.go:117: Failed to list *v1.Network: the server could not find the requested resource (get networks.samplecrd.k8s.io)
...

你可以看到,自定义控制器被启动后,一开始会报错。

这是因为,此时Network对象的CRD还没有被创建出来,所以Informer去APIServer里“获取”(List)Network对象时,并不能找到Network这个API资源类型的定义,即:

Failed to list *v1.Network: the server could not find the requested resource (get networks.samplecrd.k8s.io)

所以,接下来我就需要创建Network对象的CRD,这个操作在上一篇文章里已经介绍过了。

在另一个shell窗口里执行:

$ kubectl apply -f crd/network.yaml

这时候,你就会看到控制器的日志恢复了正常,控制循环启动成功:

...
I0915 12:50:29.051630   27159 controller.go:116] Waiting for informer caches to sync
...
I0915 12:52:54.346854   25245 controller.go:121] Starting workers
I0915 12:52:54.346914   25245 controller.go:127] Started workers

接下来,我就可以进行Network对象的增删改查操作了。

首先,创建一个Network对象:

$ cat example/example-network.yaml
apiVersion: samplecrd.k8s.io/v1
kind: Network
metadata:
  name: example-network
spec:
  cidr: "192.168.0.0/16"
  gateway: "192.168.0.1"
  
$ kubectl apply -f example/example-network.yaml
network.samplecrd.k8s.io/example-network created

这时候,查看一下控制器的输出:

...
I0915 12:50:29.051349   27159 controller.go:84] Setting up event handlers
I0915 12:50:29.051615   27159 controller.go:113] Starting Network control loop
I0915 12:50:29.051630   27159 controller.go:116] Waiting for informer caches to sync
...
I0915 12:52:54.346854   25245 controller.go:121] Starting workers
I0915 12:52:54.346914   25245 controller.go:127] Started workers
I0915 12:53:18.064409   25245 controller.go:229] [Neutron] Try to process network: &v1.Network{TypeMeta:v1.TypeMeta{Kind:"", APIVersion:""}, ObjectMeta:v1.ObjectMeta{Name:"example-network", GenerateName:"", Namespace:"default", ... ResourceVersion:"479015", ... Spec:v1.NetworkSpec{Cidr:"192.168.0.0/16", Gateway:"192.168.0.1"}} ...
I0915 12:53:18.064650   25245 controller.go:183] Successfully synced 'default/example-network'
...

可以看到,我们上面创建example-network的操作,触发了EventHandler的“添加”事件,从而被放进了工作队列。

紧接着,控制循环就从队列里拿到了这个对象,并且打印出了正在“处理”这个Network对象的日志。

可以看到,这个Network的ResourceVersion,也就是API对象的版本号,是479015,而它的Spec字段的内容,跟我提交的YAML文件一摸一样,比如,它的CIDR网段是:192.168.0.0/16。

这时候,我来修改一下这个YAML文件的内容,如下所示:

$ cat example/example-network.yaml
apiVersion: samplecrd.k8s.io/v1
kind: Network
metadata:
  name: example-network
spec:
  cidr: "192.168.1.0/16"
  gateway: "192.168.1.1"

可以看到,我把这个YAML文件里的CIDR和Gateway字段修改成了192.168.1.0/16网段。

然后,我们执行了kubectl apply命令来提交这次更新,如下所示:

$ kubectl apply -f example/example-network.yaml
network.samplecrd.k8s.io/example-network configured

这时候,我们就可以观察一下控制器的输出:

...
I0915 12:53:51.126029   25245 controller.go:229] [Neutron] Try to process network: &v1.Network{TypeMeta:v1.TypeMeta{Kind:"", APIVersion:""}, ObjectMeta:v1.ObjectMeta{Name:"example-network", GenerateName:"", Namespace:"default", ...  ResourceVersion:"479062", ... Spec:v1.NetworkSpec{Cidr:"192.168.1.0/16", Gateway:"192.168.1.1"}} ...
I0915 12:53:51.126348   25245 controller.go:183] Successfully synced 'default/example-network'

可以看到,这一次,Informer注册的“更新”事件被触发,更新后的Network对象的Key被添加到了工作队列之中。

所以,接下来控制循环从工作队列里拿到的Network对象,与前一个对象是不同的:它的ResourceVersion的值变成了479062;而Spec里的字段,则变成了192.168.1.0/16网段。

最后,我再把这个对象删除掉:

$ kubectl delete -f example/example-network.yaml

这一次,在控制器的输出里,我们就可以看到,Informer注册的“删除”事件被触发,并且控制循环“调用”Neutron API“删除”了真实环境里的网络。这个输出如下所示:

W0915 12:54:09.738464   25245 controller.go:212] Network: default/example-network does not exist in local cache, will delete it from Neutron ...
I0915 12:54:09.738832   25245 controller.go:215] [Neutron] Deleting network: default/example-network ...
I0915 12:54:09.738854   25245 controller.go:183] Successfully synced 'default/example-network'

以上,就是编写和使用自定义控制器的全部流程了。

实际上,这套流程不仅可以用在自定义API资源上,也完全可以用在Kubernetes原生的默认API对象上。

比如,我们在main函数里,除了创建一个Network Informer外,还可以初始化一个Kubernetes默认API对象的Informer工厂,比如Deployment对象的Informer。这个具体做法如下所示:

func main() {
  ...
  
  kubeInformerFactory := kubeinformers.NewSharedInformerFactory(kubeClient, time.Second*30)
  
  controller := NewController(kubeClient, exampleClient,
  kubeInformerFactory.Apps().V1().Deployments(),
  networkInformerFactory.Samplecrd().V1().Networks())
  
  go kubeInformerFactory.Start(stopCh)
  ...
}

在这段代码中,我们首先使用Kubernetes的client(kubeClient)创建了一个工厂;

然后,我用跟Network类似的处理方法,生成了一个Deployment Informer;

接着,我把Deployment Informer传递给了自定义控制器;当然,我也要调用Start方法来启动这个Deployment Informer。

而有了这个Deployment Informer后,这个控制器也就持有了所有Deployment对象的信息。接下来,它既可以通过deploymentInformer.Lister()来获取Etcd里的所有Deployment对象,也可以为这个Deployment Informer注册具体的Handler来。

更重要的是,这就使得在这个自定义控制器里面,我可以通过对自定义API对象和默认API对象进行协同,从而实现更加复杂的编排功能

比如:用户每创建一个新的Deployment,这个自定义控制器,就可以为它创建一个对应的Network供它使用。

这些对Kubernetes API编程范式的更高级应用,我就留给你在实际的场景中去探索和实践了。

总结

在今天这篇文章中,我为你剖析了Kubernetes API编程范式的具体原理,并编写了一个自定义控制器。

这其中,有如下几个概念和机制,是你一定要理解清楚的:

所谓的Informer,就是一个自带缓存和索引机制,可以触发Handler的客户端库。这个本地缓存在Kubernetes中一般被称为Store,索引一般被称为Index。

Informer使用了Reflector包,它是一个可以通过ListAndWatch机制获取并监视API对象变化的客户端封装。

Reflector和Informer之间,用到了一个“增量先进先出队列”进行协同。而Informer与你要编写的控制循环之间,则使用了一个工作队列来进行协同。

在实际应用中,除了控制循环之外的所有代码,实际上都是Kubernetes为你自动生成的,即:pkg/client/{informers, listers, clientset}里的内容。

而这些自动生成的代码,就为我们提供了一个可靠而高效地获取API对象“期望状态”的编程库。

所以,接下来,作为开发者,你就只需要关注如何拿到“实际状态”,然后如何拿它去跟“期望状态”做对比,从而决定接下来要做的业务逻辑即可。

以上内容,就是Kubernetes API编程范式的核心思想。

思考题

请思考一下,为什么Informer和你编写的控制循环之间,一定要使用一个工作队列来进行协作呢?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

26-基于角色的权限控制:RBAC

你好,我是张磊。今天我和你分享的主题是:基于角色的权限控制之RBAC。

在前面的文章中,我已经为你讲解了很多种Kubernetes内置的编排对象,以及对应的控制器模式的实现原理。此外,我还剖析了自定义API资源类型和控制器的编写方式。

这时候,你可能已经冒出了这样一个想法:控制器模式看起来好像也不难嘛,我能不能自己写一个编排对象呢?

答案当然是可以的。而且,这才是Kubernetes项目最具吸引力的地方。

毕竟,在互联网级别的大规模集群里,Kubernetes内置的编排对象,很难做到完全满足所有需求。所以,很多实际的容器化工作,都会要求你设计一个自己的编排对象,实现自己的控制器模式。

而在Kubernetes项目里,我们可以基于插件机制来完成这些工作,而完全不需要修改任何一行代码。

不过,你要通过一个外部插件,在Kubernetes里新增和操作API对象,那么就必须先了解一个非常重要的知识:RBAC。

我们知道,Kubernetes中所有的API对象,都保存在Etcd里。可是,对这些API对象的操作,却一定都是通过访问kube-apiserver实现的。其中一个非常重要的原因,就是你需要APIServer来帮助你做授权工作。

在Kubernetes项目中,负责完成授权(Authorization)工作的机制,就是RBAC:基于角色的访问控制(Role-Based Access Control)。

如果你直接查看Kubernetes项目中关于RBAC的文档的话,可能会感觉非常复杂。但实际上,等到你用到这些RBAC的细节时,再去查阅也不迟。

而在这里,我只希望你能明确三个最基本的概念。

  1. Role:角色,它其实是一组规则,定义了一组对Kubernetes API对象的操作权限。

  2. Subject:被作用者,既可以是“人”,也可以是“机器”,也可以是你在Kubernetes里定义的“用户”。

  3. RoleBinding:定义了“被作用者”和“角色”的绑定关系。

而这三个概念,其实就是整个RBAC体系的核心所在。

我先来讲解一下Role。

实际上,Role本身就是一个Kubernetes的API对象,定义如下所示:

kind: Role
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  namespace: mynamespace
  name: example-role
rules:
- apiGroups: [""]
  resources: ["pods"]
  verbs: ["get", "watch", "list"]

首先,这个Role对象指定了它能产生作用的Namepace是:mynamespace。

Namespace是Kubernetes项目里的一个逻辑管理单位。不同Namespace的API对象,在通过kubectl命令进行操作的时候,是互相隔离开的。

比如,kubectl get pods -n mynamespace。

当然,这仅限于逻辑上的“隔离”,Namespace并不会提供任何实际的隔离或者多租户能力。而在前面文章中用到的大多数例子里,我都没有指定Namespace,那就是使用的是默认Namespace:default。

然后,这个Role对象的rules字段,就是它所定义的权限规则。在上面的例子里,这条规则的含义就是:允许“被作用者”,对mynamespace下面的Pod对象,进行GET、WATCH和LIST操作。

那么,这个具体的“被作用者”又是如何指定的呢?这就需要通过RoleBinding来实现了。

当然,RoleBinding本身也是一个Kubernetes的API对象。它的定义如下所示:

kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: example-rolebinding
  namespace: mynamespace
subjects:
- kind: User
  name: example-user
  apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: Role
  name: example-role
  apiGroup: rbac.authorization.k8s.io

可以看到,这个RoleBinding对象里定义了一个subjects字段,即“被作用者”。它的类型是User,即Kubernetes里的用户。这个用户的名字是example-user。

可是,在Kubernetes中,其实并没有一个叫作“User”的API对象。而且,我们在前面和部署使用Kubernetes的流程里,既不需要User,也没有创建过User。

这个User到底是从哪里来的呢?

实际上,Kubernetes里的“User”,也就是“用户”,只是一个授权系统里的逻辑概念。它需要通过外部认证服务,比如Keystone,来提供。或者,你也可以直接给APIServer指定一个用户名、密码文件。那么Kubernetes的授权系统,就能够从这个文件里找到对应的“用户”了。当然,在大多数私有的使用环境中,我们只要使用Kubernetes提供的内置“用户”,就足够了。这部分知识,我后面马上会讲到。

接下来,我们会看到一个roleRef字段。正是通过这个字段,RoleBinding对象就可以直接通过名字,来引用我们前面定义的Role对象(example-role),从而定义了“被作用者(Subject)”和“角色(Role)”之间的绑定关系。

需要再次提醒的是,Role和RoleBinding对象都是Namespaced对象(Namespaced Object),它们对权限的限制规则仅在它们自己的Namespace内有效,roleRef也只能引用当前Namespace里的Role对象。

那么,对于非Namespaced(Non-namespaced)对象(比如:Node),或者,某一个Role想要作用于所有的Namespace的时候,我们又该如何去做授权呢?

这时候,我们就必须要使用ClusterRole和ClusterRoleBinding这两个组合了。这两个API对象的用法跟Role和RoleBinding完全一样。只不过,它们的定义里,没有了Namespace字段,如下所示:

kind: ClusterRole
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: example-clusterrole
rules:
- apiGroups: [""]
  resources: ["pods"]
  verbs: ["get", "watch", "list"]
kind: ClusterRoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: example-clusterrolebinding
subjects:
- kind: User
  name: example-user
  apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: ClusterRole
  name: example-clusterrole
  apiGroup: rbac.authorization.k8s.io

上面的例子里的ClusterRole和ClusterRoleBinding的组合,意味着名叫example-user的用户,拥有对所有Namespace里的Pod进行GET、WATCH和LIST操作的权限。

更进一步地,在Role或者ClusterRole里面,如果要赋予用户example-user所有权限,那你就可以给它指定一个verbs字段的全集,如下所示:

verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]

这些就是当前Kubernetes(v1.11)里能够对API对象进行的所有操作了。

类似地,Role对象的rules字段也可以进一步细化。比如,你可以只针对某一个具体的对象进行权限设置,如下所示:

rules:
- apiGroups: [""]
  resources: ["configmaps"]
  resourceNames: ["my-config"]
  verbs: ["get"]

这个例子就表示,这条规则的“被作用者”,只对名叫“my-config”的ConfigMap对象,有进行GET操作的权限。

而正如我前面介绍过的,在大多数时候,我们其实都不太使用“用户”这个功能,而是直接使用Kubernetes里的“内置用户”。

这个由Kubernetes负责管理的“内置用户”,正是我们前面曾经提到过的:ServiceAccount。

接下来,我通过一个具体的实例来为你讲解一下为ServiceAccount分配权限的过程。

首先,我们要定义一个ServiceAccount。它的API对象非常简单,如下所示:

apiVersion: v1
kind: ServiceAccount
metadata:
  namespace: mynamespace
  name: example-sa

可以看到,一个最简单的ServiceAccount对象只需要Name和Namespace这两个最基本的字段。

然后,我们通过编写RoleBinding的YAML文件,来为这个ServiceAccount分配权限:

kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: example-rolebinding
  namespace: mynamespace
subjects:
- kind: ServiceAccount
  name: example-sa
  namespace: mynamespace
roleRef:
  kind: Role
  name: example-role
  apiGroup: rbac.authorization.k8s.io

可以看到,在这个RoleBinding对象里,subjects字段的类型(kind),不再是一个User,而是一个名叫example-sa的ServiceAccount。而roleRef引用的Role对象,依然名叫example-role,也就是我在这篇文章一开始定义的Role对象。

接着,我们用kubectl命令创建这三个对象:

$ kubectl create -f svc-account.yaml
$ kubectl create -f role-binding.yaml
$ kubectl create -f role.yaml

然后,我们来查看一下这个ServiceAccount的详细信息:

$ kubectl get sa -n mynamespace -o yaml
- apiVersion: v1
  kind: ServiceAccount
  metadata:
    creationTimestamp: 2018-09-08T12:59:17Z
    name: example-sa
    namespace: mynamespace
    resourceVersion: "409327"
    ...
  secrets:
  - name: example-sa-token-vmfg6

可以看到,Kubernetes会为一个ServiceAccount自动创建并分配一个Secret对象,即:上述ServiceAcount定义里最下面的secrets字段。

这个Secret,就是这个ServiceAccount对应的、用来跟APIServer进行交互的授权文件,我们一般称它为:Token。Token文件的内容一般是证书或者密码,它以一个Secret对象的方式保存在Etcd当中。

这时候,用户的Pod,就可以声明使用这个ServiceAccount了,比如下面这个例子:

apiVersion: v1
kind: Pod
metadata:
  namespace: mynamespace
  name: sa-token-test
spec:
  containers:
  - name: nginx
    image: nginx:1.7.9
  serviceAccountName: example-sa

在这个例子里,我定义了Pod要使用的要使用的ServiceAccount的名字是:example-sa。

等这个Pod运行起来之后,我们就可以看到,该ServiceAccount的token,也就是一个Secret对象,被Kubernetes自动挂载到了容器的/var/run/secrets/kubernetes.io/serviceaccount目录下,如下所示:

$ kubectl describe pod sa-token-test -n mynamespace
Name:               sa-token-test
Namespace:          mynamespace
...
Containers:
  nginx:
    ...
    Mounts:
      /var/run/secrets/kubernetes.io/serviceaccount from example-sa-token-vmfg6 (ro)

这时候,我们可以通过kubectl exec查看到这个目录里的文件:

$ kubectl exec -it sa-token-test -n mynamespace -- /bin/bash
root@sa-token-test:/# ls /var/run/secrets/kubernetes.io/serviceaccount
ca.crt namespace  token

如上所示,容器里的应用,就可以使用这个ca.crt来访问APIServer了。更重要的是,此时它只能够做GET、WATCH和LIST操作。因为example-sa这个ServiceAccount的权限,已经被我们绑定了Role做了限制。

此外,我在第15篇文章《深入解析Pod对象(二):使用进阶》中曾经提到过,如果一个Pod没有声明serviceAccountName,Kubernetes会自动在它的Namespace下创建一个名叫default的默认ServiceAccount,然后分配给这个Pod。

但在这种情况下,这个默认ServiceAccount并没有关联任何Role。也就是说,此时它有访问APIServer的绝大多数权限。当然,这个访问所需要的Token,还是默认ServiceAccount对应的Secret对象为它提供的,如下所示。

$kubectl describe sa default
Name:                default
Namespace:           default
Labels:              <none>
Annotations:         <none>
Image pull secrets:  <none>
Mountable secrets:   default-token-s8rbq
Tokens:              default-token-s8rbq
Events:              <none>

$ kubectl get secret
NAME                  TYPE                                  DATA      AGE
kubernetes.io/service-account-token   3         82d

$ kubectl describe secret default-token-s8rbq
Name:         default-token-s8rbq
Namespace:    default
Labels:       <none>
Annotations:  kubernetes.io/service-account.name=default
              kubernetes.io/service-account.uid=ffcb12b2-917f-11e8-abde-42010aa80002

Type:  kubernetes.io/service-account-token

Data
====
ca.crt:     1025 bytes
namespace:  7 bytes
token:      <TOKEN数据>

可以看到,Kubernetes会自动为默认ServiceAccount创建并绑定一个特殊的Secret:它的类型是kubernetes.io/service-account-token;它的Annotation字段,声明了kubernetes.io/service-account.name=default,即这个Secret会跟同一Namespace下名叫default的ServiceAccount进行绑定。

所以,在生产环境中,我强烈建议你为所有Namespace下的默认ServiceAccount,绑定一个只读权限的Role。这个具体怎么做,就当作思考题留给你了。

除了前面使用的“用户”(User),Kubernetes还拥有“用户组”(Group)的概念,也就是一组“用户”的意思。如果你为Kubernetes配置了外部认证服务的话,这个“用户组”的概念就会由外部认证服务提供。

而对于Kubernetes的内置“用户”ServiceAccount来说,上述“用户组”的概念也同样适用。

实际上,一个ServiceAccount,在Kubernetes里对应的“用户”的名字是:

system:serviceaccount:<Namespace名字>:<ServiceAccount名字>

而它对应的内置“用户组”的名字,就是:

system:serviceaccounts:<Namespace名字>

这两个对应关系,请你一定要牢记。

比如,现在我们可以在RoleBinding里定义如下的subjects:

subjects:
- kind: Group
  name: system:serviceaccounts:mynamespace
  apiGroup: rbac.authorization.k8s.io

这就意味着这个Role的权限规则,作用于mynamespace里的所有ServiceAccount。这就用到了“用户组”的概念。

而下面这个例子:

subjects:
- kind: Group
  name: system:serviceaccounts
  apiGroup: rbac.authorization.k8s.io

就意味着这个Role的权限规则,作用于整个系统里的所有ServiceAccount。

最后,值得一提的是,在Kubernetes中已经内置了很多个为系统保留的ClusterRole,它们的名字都以system:开头。你可以通过kubectl get clusterroles查看到它们。

一般来说,这些系统ClusterRole,是绑定给Kubernetes系统组件对应的ServiceAccount使用的。

比如,其中一个名叫system:kube-scheduler的ClusterRole,定义的权限规则是kube-scheduler(Kubernetes的调度器组件)运行所需要的必要权限。你可以通过如下指令查看这些权限的列表:

$ kubectl describe clusterrole system:kube-scheduler
Name:         system:kube-scheduler
...
PolicyRule:
  Resources                    Non-Resource URLs Resource Names    Verbs
  ---------                    -----------------  --------------    -----
...
  services                     []                 []                [get list watch]
  replicasets.apps             []                 []                [get list watch]
  statefulsets.apps            []                 []                [get list watch]
  replicasets.extensions       []                 []                [get list watch]
  poddisruptionbudgets.policy  []                 []                [get list watch]
  pods/status                  []                 []                [patch update]

这个system:kube-scheduler的ClusterRole,就会被绑定给kube-system Namesapce下名叫kube-scheduler的ServiceAccount,它正是Kubernetes调度器的Pod声明使用的ServiceAccount。

除此之外,Kubernetes还提供了四个预先定义好的ClusterRole来供用户直接使用:

  1. cluster-admin;

  2. admin;

  3. edit;

  4. view。

通过它们的名字,你应该能大致猜出它们都定义了哪些权限。比如,这个名叫view的ClusterRole,就规定了被作用者只有Kubernetes API的只读权限。

而我还要提醒你的是,上面这个cluster-admin角色,对应的是整个Kubernetes项目中的最高权限(verbs=*),如下所示:

$ kubectl describe clusterrole cluster-admin -n kube-system
Name:         cluster-admin
Labels:       kubernetes.io/bootstrapping=rbac-defaults
Annotations:  rbac.authorization.kubernetes.io/autoupdate=true
PolicyRule:
  Resources  Non-Resource URLs Resource Names  Verbs
  ---------  -----------------  --------------  -----
  *.*        []                 []              [*]
             [*]                []              [*]

所以,请你务必要谨慎而小心地使用cluster-admin。

总结

在今天这篇文章中,我主要为你讲解了基于角色的访问控制(RBAC)。

其实,你现在已经能够理解,所谓角色(Role),其实就是一组权限规则列表。而我们分配这些权限的方式,就是通过创建RoleBinding对象,将被作用者(subject)和权限列表进行绑定。

另外,与之对应的ClusterRole和ClusterRoleBinding,则是Kubernetes集群级别的Role和RoleBinding,它们的作用范围不受Namespace限制。

而尽管权限的被作用者可以有很多种(比如,User、Group等),但在我们平常的使用中,最普遍的用法还是ServiceAccount。所以,Role + RoleBinding + ServiceAccount的权限分配方式是你要重点掌握的内容。我们在后面编写和安装各种插件的时候,会经常用到这个组合。

思考题

请问,如何为所有Namespace下的默认ServiceAccount(default ServiceAccount),绑定一个只读权限的Role呢?请你提供ClusterRoleBinding(或者RoleBinding)的YAML文件。

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

27-聪明的微创新:Operator工作原理解读

你好,我是张磊。今天我和你分享的主题是:聪明的微创新之Operator工作原理解读。

在前面的几篇文章中,我已经和你分享了Kubernetes项目中的大部分编排对象(比如Deployment、StatefulSet、DaemonSet,以及Job),也介绍了“有状态应用”的管理方法,还阐述了为Kubernetes添加自定义API对象和编写自定义控制器的原理和流程。

可能你已经感觉到,在Kubernetes中,管理“有状态应用”是一个比较复杂的过程,尤其是编写Pod模板的时候,总有一种“在YAML文件里编程序”的感觉,让人很不舒服。

而在Kubernetes生态中,还有一个相对更加灵活和编程友好的管理“有状态应用”的解决方案,它就是:Operator。

接下来,我就以Etcd Operator为例,来为你讲解一下Operator的工作原理和编写方法。

Etcd Operator的使用方法非常简单,只需要两步即可完成:

第一步,将这个Operator的代码Clone到本地:

$ git clone https://github.com/coreos/etcd-operator

第二步,将这个Etcd Operator部署在Kubernetes集群里。

不过,在部署Etcd Operator的Pod之前,你需要先执行这样一个脚本:

$ example/rbac/create_role.sh

不用我多说你也能够明白:这个脚本的作用,就是为Etcd Operator创建RBAC规则。这是因为,Etcd Operator需要访问Kubernetes的APIServer来创建对象。

更具体地说,上述脚本为Etcd Operator定义了如下所示的权限:

  1. 对Pod、Service、PVC、Deployment、Secret等API对象,有所有权限;

  2. 对CRD对象,有所有权限;

  3. 对属于etcd.database.coreos.com这个API Group的CR(Custom Resource)对象,有所有权限。

而Etcd Operator本身,其实就是一个Deployment,它的YAML文件如下所示:

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: etcd-operator
spec:
  replicas: 1
  template:
    metadata:
      labels:
        name: etcd-operator
    spec:
      containers:
      - name: etcd-operator
        image: quay.io/coreos/etcd-operator:v0.9.2
        command:
        - etcd-operator
        env:
        - name: MY_POD_NAMESPACE
          valueFrom:
            fieldRef:
              fieldPath: metadata.namespace
        - name: MY_POD_NAME
          valueFrom:
            fieldRef:
              fieldPath: metadata.name
...

所以,我们就可以使用上述的YAML文件来创建Etcd Operator,如下所示:

$ kubectl create -f example/deployment.yaml

而一旦Etcd Operator的Pod进入了Running状态,你就会发现,有一个CRD被自动创建了出来,如下所示:

$ kubectl get pods
NAME                              READY     STATUS      RESTARTS   AGE
etcd-operator-649dbdb5cb-bzfzp    1/1       Running     0          20s

$ kubectl get crd
NAME                                    CREATED AT
etcdclusters.etcd.database.coreos.com   2018-09-18T11:42:55Z

这个CRD名叫etcdclusters.etcd.database.coreos.com 。你可以通过kubectl describe命令看到它的细节,如下所示:

$ kubectl describe crd  etcdclusters.etcd.database.coreos.com
...
Group:   etcd.database.coreos.com
  Names:
    Kind:       EtcdCluster
    List Kind:  EtcdClusterList
    Plural:     etcdclusters
    Short Names:
      etcd
    Singular:  etcdcluster
  Scope:       Namespaced
  Version:     v1beta2
  
...

可以看到,这个CRD相当于告诉了Kubernetes:接下来,如果有API组(Group)是etcd.database.coreos.com、API资源类型(Kind)是“EtcdCluster”的YAML文件被提交上来,你可一定要认识啊。

所以说,通过上述两步操作,你实际上是在Kubernetes里添加了一个名叫EtcdCluster的自定义资源类型。而Etcd Operator本身,就是这个自定义资源类型对应的自定义控制器。

而当Etcd Operator部署好之后,接下来在这个Kubernetes里创建一个Etcd集群的工作就非常简单了。你只需要编写一个EtcdCluster的YAML文件,然后把它提交给Kubernetes即可,如下所示:

$ kubectl apply -f example/example-etcd-cluster.yaml

这个example-etcd-cluster.yaml文件里描述的,是一个3个节点的Etcd集群。我们可以看到它被提交给Kubernetes之后,就会有三个Etcd的Pod运行起来,如下所示:

$ kubectl get pods
NAME                            READY     STATUS    RESTARTS   AGE
example-etcd-cluster-dp8nqtjznc   1/1       Running     0          1m
example-etcd-cluster-mbzlg6sd56   1/1       Running     0          2m
example-etcd-cluster-v6v6s6stxd   1/1       Running     0          2m

那么,究竟发生了什么,让创建一个Etcd集群的工作如此简单呢?

我们当然还是得从这个example-etcd-cluster.yaml文件开始说起。

不难想到,这个文件里定义的,正是EtcdCluster这个CRD的一个具体实例,也就是一个Custom Resource(CR)。而它的内容非常简单,如下所示:

apiVersion: "etcd.database.coreos.com/v1beta2"
kind: "EtcdCluster"
metadata:
  name: "example-etcd-cluster"
spec:
  size: 3
  version: "3.2.13"

可以看到,EtcdCluster的spec字段非常简单。其中,size=3指定了它所描述的Etcd集群的节点个数。而version=“3.2.13”,则指定了Etcd的版本,仅此而已。

而真正把这样一个Etcd集群创建出来的逻辑,就是Etcd Operator要实现的主要工作了。

看到这里,相信你应该已经对Operator有了一个初步的认知:

Operator的工作原理,实际上是利用了Kubernetes的自定义API资源(CRD),来描述我们想要部署的“有状态应用”;然后在自定义控制器里,根据自定义API对象的变化,来完成具体的部署和运维工作。

所以,编写一个Etcd Operator,与我们前面编写一个自定义控制器的过程,没什么不同。

不过,考虑到你可能还不太清楚Etcd集群的组建方式,我在这里先简单介绍一下这部分知识。

Etcd Operator部署Etcd集群,采用的是静态集群(Static)的方式

静态集群的好处是,它不必依赖于一个额外的服务发现机制来组建集群,非常适合本地容器化部署。而它的难点,则在于你必须在部署的时候,就规划好这个集群的拓扑结构,并且能够知道这些节点固定的IP地址。比如下面这个例子:

$ etcd --name infra0 --initial-advertise-peer-urls http://10.0.1.10:2380 \
  --listen-peer-urls http://10.0.1.10:2380 \
...
  --initial-cluster-token etcd-cluster-1 \
  --initial-cluster infra0=http://10.0.1.10:2380,infra1=http://10.0.1.11:2380,infra2=http://10.0.1.12:2380 \
  --initial-cluster-state new
  
$ etcd --name infra1 --initial-advertise-peer-urls http://10.0.1.11:2380 \
  --listen-peer-urls http://10.0.1.11:2380 \
...
  --initial-cluster-token etcd-cluster-1 \
  --initial-cluster infra0=http://10.0.1.10:2380,infra1=http://10.0.1.11:2380,infra2=http://10.0.1.12:2380 \
  --initial-cluster-state new
  
$ etcd --name infra2 --initial-advertise-peer-urls http://10.0.1.12:2380 \
  --listen-peer-urls http://10.0.1.12:2380 \
...
  --initial-cluster-token etcd-cluster-1 \
  --initial-cluster infra0=http://10.0.1.10:2380,infra1=http://10.0.1.11:2380,infra2=http://10.0.1.12:2380 \
  --initial-cluster-state new

在这个例子中,我启动了三个Etcd进程,组成了一个三节点的Etcd集群。

其中,这些节点启动参数里的–initial-cluster参数,非常值得你关注。它的含义,正是当前节点启动时集群的拓扑结构。说得更详细一点,就是当前这个节点启动时,需要跟哪些节点通信来组成集群

举个例子,我们可以看一下上述infra2节点的–initial-cluster的值,如下所示:

...
--initial-cluster infra0=http://10.0.1.10:2380,infra1=http://10.0.1.11:2380,infra2=http://10.0.1.12:2380 \

可以看到,–initial-cluster参数是由“<节点名字>=<节点地址>”格式组成的一个数组。而上面这个配置的意思就是,当infra2节点启动之后,这个Etcd集群里就会有infra0、infra1和infra2三个节点。

同时,这些Etcd节点,需要通过2380端口进行通信以便组成集群,这也正是上述配置中–listen-peer-urls字段的含义。

此外,一个Etcd集群还需要用–initial-cluster-token字段,来声明一个该集群独一无二的Token名字。

像上述这样为每一个Ectd节点配置好它对应的启动参数之后把它们启动起来,一个Etcd集群就可以自动组建起来了。

而我们要编写的Etcd Operator,就是要把上述过程自动化。这其实等同于:用代码来生成每个Etcd节点Pod的启动命令,然后把它们启动起来。

接下来,我们一起来实践一下这个流程。

当然,在编写自定义控制器之前,我们首先需要完成EtcdCluster这个CRD的定义,它对应的types.go文件的主要内容,如下所示:

// +genclient
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object

type EtcdCluster struct {
  metav1.TypeMeta   `json:",inline"`
  metav1.ObjectMeta `json:"metadata,omitempty"`
  Spec              ClusterSpec   `json:"spec"`
  Status            ClusterStatus `json:"status"`
}

type ClusterSpec struct {
 // Size is the expected size of the etcd cluster.
 // The etcd-operator will eventually make the size of the running
 // cluster equal to the expected size.
 // The vaild range of the size is from 1 to 7.
 Size int `json:"size"`
 ...
}

可以看到,EtcdCluster是一个有Status字段的CRD。在这里,我们可以不必关心ClusterSpec里的其他字段,只关注Size(即:Etcd集群的大小)字段即可。

Size字段的存在,就意味着将来如果我们想要调整集群大小的话,应该直接修改YAML文件里size的值,并执行kubectl apply -f。

这样,Operator就会帮我们完成Etcd节点的增删操作。这种“scale”能力,也是Etcd Operator自动化运维Etcd集群需要实现的主要功能。

而为了能够支持这个功能,我们就不再像前面那样在–initial-cluster参数里把拓扑结构固定死。

所以,Etcd Operator的实现,虽然选择的也是静态集群,但这个集群具体的组建过程,是逐个节点动态添加的方式,即:

首先,Etcd Operator会创建一个“种子节点”;
然后,Etcd Operator会不断创建新的Etcd节点,然后将它们逐一加入到这个集群当中,直到集群的节点数等于size。

这就意味着,在生成不同角色的Etcd Pod时,Operator需要能够区分种子节点与普通节点。

而这两种节点的不同之处,就在于一个名叫–initial-cluster-state的启动参数:

  • 当这个参数值设为new时,就代表了该节点是种子节点。而我们前面提到过,种子节点还必须通过–initial-cluster-token声明一个独一无二的Token。
  • 而如果这个参数值设为existing,那就是说明这个节点是一个普通节点,Etcd Operator需要把它加入到已有集群里。

那么接下来的问题就是,每个Etcd节点的–initial-cluster字段的值又是怎么生成的呢?

由于这个方案要求种子节点先启动,所以对于种子节点infra0来说,它启动后的集群只有它自己,即:–initial-cluster=infra0=http://10.0.1.10:2380。

而对于接下来要加入的节点,比如infra1来说,它启动后的集群就有两个节点了,所以它的–initial-cluster参数的值应该是:infra0=http://10.0.1.10:2380,infra1=http://10.0.1.11:2380。

其他节点,都以此类推。

现在,你就应该能在脑海中构思出上述三节点Etcd集群的部署过程了。

首先,只要用户提交YAML文件时声明创建一个EtcdCluster对象(一个Etcd集群),那么Etcd Operator都应该先创建一个单节点的种子集群(Seed Member),并启动这个种子节点。

以infra0节点为例,它的IP地址是10.0.1.10,那么Etcd Operator生成的种子节点的启动命令,如下所示:

$ etcd
  --data-dir=/var/etcd/data
  --name=infra0
  --initial-advertise-peer-urls=http://10.0.1.10:2380
  --listen-peer-urls=http://0.0.0.0:2380
  --listen-client-urls=http://0.0.0.0:2379
  --advertise-client-urls=http://10.0.1.10:2379
  --initial-cluster=infra0=http://10.0.1.10:2380
  --initial-cluster-state=new
  --initial-cluster-token=4b5215fa-5401-4a95-a8c6-892317c9bef8

可以看到,这个种子节点的initial-cluster-state是new,并且指定了唯一的initial-cluster-token参数。

我们可以把这个创建种子节点(集群)的阶段称为:Bootstrap

接下来,对于其他每一个节点,Operator只需要执行如下两个操作即可,以infra1为例。

第一步:通过Etcd命令行添加一个新成员:

$ etcdctl member add infra1 http://10.0.1.11:2380

第二步:为这个成员节点生成对应的启动参数,并启动它:

$ etcd
    --data-dir=/var/etcd/data
    --name=infra1
    --initial-advertise-peer-urls=http://10.0.1.11:2380
    --listen-peer-urls=http://0.0.0.0:2380
    --listen-client-urls=http://0.0.0.0:2379
    --advertise-client-urls=http://10.0.1.11:2379
    --initial-cluster=infra0=http://10.0.1.10:2380,infra1=http://10.0.1.11:2380
    --initial-cluster-state=existing

可以看到,对于这个infra1成员节点来说,它的initial-cluster-state是existing,也就是要加入已有集群。而它的initial-cluster的值,则变成了infra0和infra1两个节点的IP地址。

所以,以此类推,不断地将infra2等后续成员添加到集群中,直到整个集群的节点数目等于用户指定的size之后,部署就完成了。

在熟悉了这个部署思路之后,我再为你讲解Etcd Operator的工作原理,就非常简单了。

跟所有的自定义控制器一样,Etcd Operator的启动流程也是围绕着Informer展开的,如下所示:

func (c *Controller) Start() error {
 for {
  err := c.initResource()
  ...
  time.Sleep(initRetryWaitTime)
 }
 c.run()
}

func (c *Controller) run() {
 ...
 
 _, informer := cache.NewIndexerInformer(source, &api.EtcdCluster{}, 0, cache.ResourceEventHandlerFuncs{
  AddFunc:    c.onAddEtcdClus,
  UpdateFunc: c.onUpdateEtcdClus,
  DeleteFunc: c.onDeleteEtcdClus,
 }, cache.Indexers{})
 
 ctx := context.TODO()
 // TODO: use workqueue to avoid blocking
 informer.Run(ctx.Done())
}

可以看到,Etcd Operator启动要做的第一件事( c.initResource),是创建EtcdCluster对象所需要的CRD,即:前面提到的etcdclusters.etcd.database.coreos.com。这样Kubernetes就能够“认识”EtcdCluster这个自定义API资源了。

接下来,Etcd Operator会定义一个EtcdCluster对象的Informer

不过,需要注意的是,由于Etcd Operator的完成时间相对较早,所以它里面有些代码的编写方式会跟我们之前讲解的最新的编写方式不太一样。在具体实践的时候,你还是应该以我讲解的模板为主。

比如,在上面的代码最后,你会看到有这样一句注释:

// TODO: use workqueue to avoid blocking
...

也就是说,Etcd Operator并没有用工作队列来协调Informer和控制循环。这其实正是我在第25篇文章《深入解析声明式API(二):编写自定义控制器》中,给你留的关于工作队列的思考题的答案。

具体来讲,我们在控制循环里执行的业务逻辑,往往是比较耗时间的。比如,创建一个真实的Etcd集群。而Informer的WATCH机制对API对象变化的响应,则非常迅速。所以,控制器里的业务逻辑就很可能会拖慢Informer的执行周期,甚至可能Block它。而要协调这样两个快、慢任务的一个典型解决方法,就是引入一个工作队列。

备注:如果你感兴趣的话,可以给Etcd Operator提一个patch来修复这个问题。提PR修TODO,是给一个开源项目做有意义的贡献的一个重要方式。

由于Etcd Operator里没有工作队列,那么在它的EventHandler部分,就不会有什么入队操作,而直接就是每种事件对应的具体的业务逻辑了。

不过,Etcd Operator在业务逻辑的实现方式上,与常规的自定义控制器略有不同。我把在这一部分的工作原理,提炼成了一个详细的流程图,如下所示:

可以看到,Etcd Operator的特殊之处在于,它为每一个EtcdCluster对象,都启动了一个控制循环,“并发”地响应这些对象的变化。显然,这种做法不仅可以简化Etcd Operator的代码实现,还有助于提高它的响应速度。

以文章一开始的example-etcd-cluster的YAML文件为例。

当这个YAML文件第一次被提交到Kubernetes之后,Etcd Operator的Informer,就会立刻“感知”到一个新的EtcdCluster对象被创建了出来。所以,EventHandler里的“添加”事件会被触发。

而这个Handler要做的操作也很简单,即:在Etcd Operator内部创建一个对应的Cluster对象(cluster.New),比如流程图里的Cluster1。

这个Cluster对象,就是一个Etcd集群在Operator内部的描述,所以它与真实的Etcd集群的生命周期是一致的。

而一个Cluster对象需要具体负责的,其实有两个工作。

其中,第一个工作只在该Cluster对象第一次被创建的时候才会执行。这个工作,就是我们前面提到过的Bootstrap,即:创建一个单节点的种子集群。

由于种子集群只有一个节点,所以这一步直接就会生成一个Etcd的Pod对象。这个Pod里有一个InitContainer,负责检查Pod的DNS记录是否正常。如果检查通过,用户容器也就是Etcd容器就会启动起来。

而这个Etcd容器最重要的部分,当然就是它的启动命令了。

以我们在文章一开始部署的集群为例,它的种子节点的容器启动命令如下所示:

/usr/local/bin/etcd
  --data-dir=/var/etcd/data
  --name=example-etcd-cluster-mbzlg6sd56
  --initial-advertise-peer-urls=http://example-etcd-cluster-mbzlg6sd56.example-etcd-cluster.default.svc:2380
  --listen-peer-urls=http://0.0.0.0:2380
  --listen-client-urls=http://0.0.0.0:2379
  --advertise-client-urls=http://example-etcd-cluster-mbzlg6sd56.example-etcd-cluster.default.svc:2379
  --initial-cluster=example-etcd-cluster-mbzlg6sd56=http://example-etcd-cluster-mbzlg6sd56.example-etcd-cluster.default.svc:2380
  --initial-cluster-state=new
  --initial-cluster-token=4b5215fa-5401-4a95-a8c6-892317c9bef8

上述启动命令里的各个参数的含义,我已经在前面介绍过。

可以看到,在这些启动参数(比如:initial-cluster)里,Etcd Operator只会使用Pod的DNS记录,而不是它的IP地址。

这当然是因为,在Operator生成上述启动命令的时候,Etcd的Pod还没有被创建出来,它的IP地址自然也无从谈起。

这也就意味着,每个Cluster对象,都会事先创建一个与该EtcdCluster同名的Headless Service。这样,Etcd Operator在接下来的所有创建Pod的步骤里,就都可以使用Pod的DNS记录来代替它的IP地址了。

备注:Headless Service的DNS记录格式是:...svc.cluster.local。如果你记不太清楚了,可以借此再回顾一下第18篇文章《深入理解StatefulSet(一):拓扑状态》中的相关内容。

Cluster对象的第二个工作,则是启动该集群所对应的控制循环。

这个控制循环每隔一定时间,就会执行一次下面的Diff流程。

首先,控制循环要获取到所有正在运行的、属于这个Cluster的Pod数量,也就是该Etcd集群的“实际状态”。

而这个Etcd集群的“期望状态”,正是用户在EtcdCluster对象里定义的size。

所以接下来,控制循环会对比这两个状态的差异。

如果实际的Pod数量不够,那么控制循环就会执行一个添加成员节点的操作(即:上述流程图中的addOneMember方法);反之,就执行删除成员节点的操作(即:上述流程图中的removeOneMember方法)。

以addOneMember方法为例,它执行的流程如下所示:

  1. 生成一个新节点的Pod的名字,比如:example-etcd-cluster-v6v6s6stxd;

  2. 调用Etcd Client,执行前面提到过的etcdctl member add example-etcd-cluster-v6v6s6stxd命令;

  3. 使用这个Pod名字,和已经存在的所有节点列表,组合成一个新的initial-cluster字段的值;

  4. 使用这个initial-cluster的值,生成这个Pod里Etcd容器的启动命令。如下所示:

/usr/local/bin/etcd
  --data-dir=/var/etcd/data
  --name=example-etcd-cluster-v6v6s6stxd
  --initial-advertise-peer-urls=http://example-etcd-cluster-v6v6s6stxd.example-etcd-cluster.default.svc:2380
  --listen-peer-urls=http://0.0.0.0:2380
  --listen-client-urls=http://0.0.0.0:2379
  --advertise-client-urls=http://example-etcd-cluster-v6v6s6stxd.example-etcd-cluster.default.svc:2379
  --initial-cluster=example-etcd-cluster-mbzlg6sd56=http://example-etcd-cluster-mbzlg6sd56.example-etcd-cluster.default.svc:2380,example-etcd-cluster-v6v6s6stxd=http://example-etcd-cluster-v6v6s6stxd.example-etcd-cluster.default.svc:2380
  --initial-cluster-state=existing

这样,当这个容器启动之后,一个新的Etcd成员节点就被加入到了集群当中。控制循环会重复这个过程,直到正在运行的Pod数量与EtcdCluster指定的size一致。

在有了这样一个与EtcdCluster对象一一对应的控制循环之后,你后续对这个EtcdCluster的任何修改,比如:修改size或者Etcd的version,它们对应的更新事件都会由这个Cluster对象的控制循环进行处理。

以上,就是一个Etcd Operator的工作原理了。

如果对比一下Etcd Operator与我在第20篇文章《深入理解StatefulSet(三):有状态应用实践》中讲解过的MySQL StatefulSet的话,你可能会有两个问题。

第一个问题是,在StatefulSet里,它为Pod创建的名字是带编号的,这样就把整个集群的拓扑状态固定了下来(比如:一个三节点的集群一定是由名叫web-0、web-1和web-2的三个Pod组成)。可是,在Etcd Operator里,为什么我们使用随机名字就可以了呢?

这是因为,Etcd Operator在每次添加Etcd节点的时候,都会先执行etcdctl member add <Pod名字>;每次删除节点的时候,则会执行etcdctl member remove <Pod名字>。这些操作,其实就会更新Etcd内部维护的拓扑信息,所以Etcd Operator无需在集群外部通过编号来固定这个拓扑关系。

第二个问题是,为什么我没有在EtcdCluster对象里声明Persistent Volume?

难道,我们不担心节点宕机之后Etcd的数据会丢失吗?

我们知道,Etcd是一个基于Raft协议实现的高可用Key-Value存储。根据Raft协议的设计原则,当Etcd集群里只有半数以下(在我们的例子里,小于等于一个)的节点失效时,当前集群依然可以正常工作。此时,Etcd Operator只需要通过控制循环创建出新的Pod,然后将它们加入到现有集群里,就完成了“期望状态”与“实际状态”的调谐工作。这个集群,是一直可用的 。

备注:关于Etcd的工作原理和Raft协议的设计思想,你可以阅读这篇文章来进行学习。

但是,当这个Etcd集群里有半数以上(在我们的例子里,大于等于两个)的节点失效的时候,这个集群就会丧失数据写入的能力,从而进入“不可用”状态。此时,即使Etcd Operator创建出新的Pod出来,Etcd集群本身也无法自动恢复起来。

这个时候,我们就必须使用Etcd本身的备份数据来对集群进行恢复操作。

在有了Operator机制之后,上述Etcd的备份操作,是由一个单独的Etcd Backup Operator负责完成的。

创建和使用这个Operator的流程,如下所示:

# 首先,创建etcd-backup-operator
$ kubectl create -f example/etcd-backup-operator/deployment.yaml

# 确认etcd-backup-operator已经在正常运行
$ kubectl get pod
NAME                                    READY     STATUS    RESTARTS   AGE
etcd-backup-operator-1102130733-hhgt7   1/1       Running   0          3s

# 可以看到,Backup Operator会创建一个叫etcdbackups的CRD
$ kubectl get crd
NAME                                    KIND
etcdbackups.etcd.database.coreos.com    CustomResourceDefinition.v1beta1.apiextensions.k8s.io

# 我们这里要使用AWS S3来存储备份,需要将S3的授权信息配置在文件里
$ cat $AWS_DIR/credentials
[default]
aws_access_key_id = XXX
aws_secret_access_key = XXX

$ cat $AWS_DIR/config
[default]
region = <region>

# 然后,将上述授权信息制作成一个Secret
$ kubectl create secret generic aws --from-file=$AWS_DIR/credentials --from-file=$AWS_DIR/config

# 使用上述S3的访问信息,创建一个EtcdBackup对象
$ sed -e 's|<full-s3-path>|mybucket/etcd.backup|g' \
    -e 's|<aws-secret>|aws|g' \
    -e 's|<etcd-cluster-endpoints>|"http://example-etcd-cluster-client:2379"|g' \
    example/etcd-backup-operator/backup_cr.yaml \
    | kubectl create -f -

需要注意的是,每当你创建一个EtcdBackup对象(backup_cr.yaml),就相当于为它所指定的Etcd集群做了一次备份。EtcdBackup对象的etcdEndpoints字段,会指定它要备份的Etcd集群的访问地址。

所以,在实际的环境里,我建议你把最后这个备份操作,编写成一个Kubernetes的CronJob以便定时运行。

而当Etcd集群发生了故障之后,你就可以通过创建一个EtcdRestore对象来完成恢复操作。当然,这就意味着你也需要事先启动Etcd Restore Operator。

这个流程的完整过程,如下所示:

# 创建etcd-restore-operator
$ kubectl create -f example/etcd-restore-operator/deployment.yaml

# 确认它已经正常运行
$ kubectl get pods
NAME                                     READY     STATUS    RESTARTS   AGE
etcd-restore-operator-4203122180-npn3g   1/1       Running   0          7s

# 创建一个EtcdRestore对象,来帮助Etcd Operator恢复数据,记得替换模板里的S3的访问信息
$ sed -e 's|<full-s3-path>|mybucket/etcd.backup|g' \
    -e 's|<aws-secret>|aws|g' \
    example/etcd-restore-operator/restore_cr.yaml \
    | kubectl create -f -

上面例子里的EtcdRestore对象(restore_cr.yaml),会指定它要恢复的Etcd集群的名字和备份数据所在的S3存储的访问信息。

而当一个EtcdRestore对象成功创建后,Etcd Restore Operator就会通过上述信息,恢复出一个全新的Etcd集群。然后,Etcd Operator会把这个新集群直接接管过来,从而重新进入可用的状态。

EtcdBackup和EtcdRestore这两个Operator的工作原理,与Etcd Operator的实现方式非常类似。所以,这一部分就交给你课后去探索了。

总结

在今天这篇文章中,我以Etcd Operator为例,详细介绍了一个Operator的工作原理和编写过程。

可以看到,Etcd集群本身就拥有良好的分布式设计和一定的高可用能力。在这种情况下,StatefulSet“为Pod编号”和“将Pod同PV绑定”这两个主要的特性,就不太有用武之地了。

而相比之下,Etcd Operator把一个Etcd集群,抽象成了一个具有一定“自治能力”的整体。而当这个“自治能力”本身不足以解决问题的时候,我们可以通过两个专门负责备份和恢复的Operator进行修正。这种实现方式,不仅更加贴近Etcd的设计思想,也更加编程友好。

不过,如果我现在要部署的应用,既需要用StatefulSet的方式维持拓扑状态和存储状态,又有大量的编程工作要做,那我到底该如何选择呢?

其实,Operator和StatefulSet并不是竞争关系。你完全可以编写一个Operator,然后在Operator的控制循环里创建和控制StatefulSet而不是Pod。比如,业界知名的Prometheus项目的Operator,正是这么实现的。

此外,CoreOS公司在被RedHat公司收购之后,已经把Operator的编写过程封装成了一个叫作Operator SDK的工具(整个项目叫作Operator Framework),它可以帮助你生成Operator的框架代码。感兴趣的话,你可以试用一下。

思考题

在Operator的实现过程中,我们再一次用到了CRD。可是,你一定要明白,CRD并不是万能的,它有很多场景不适用,还有性能瓶颈。你能列举出一些不适用CRD的场景么?你知道造成CRD性能瓶颈的原因主要在哪里么?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

28-PV、PVC、StorageClass,这些到底在说啥?

你好,我是张磊。今天我和你分享的主题是:PV、PVC、StorageClass,这些到底在说啥?

在前面的文章中,我重点为你分析了Kubernetes的各种编排能力。

在这些讲解中,你应该已经发现,容器化一个应用比较麻烦的地方,莫过于对其“状态”的管理。而最常见的“状态”,又莫过于存储状态了。

所以,从今天这篇文章开始,我会通过4篇文章为你剖析Kubernetes项目处理容器持久化存储的核心原理,从而帮助你更好地理解和使用这部分内容。

首先,我们来回忆一下我在第19篇文章《深入理解StatefulSet(二):存储状态》中,和你分享StatefulSet如何管理存储状态的时候,介绍过的Persistent Volume(PV)和Persistent Volume Claim(PVC)这套持久化存储体系。

其中,PV描述的,是持久化存储数据卷。这个API对象主要定义的是一个持久化存储在宿主机上的目录,比如一个NFS的挂载目录。

通常情况下,PV对象是由运维人员事先创建在Kubernetes集群里待用的。比如,运维人员可以定义这样一个NFS类型的PV,如下所示:

apiVersion: v1
kind: PersistentVolume
metadata:
  name: nfs
spec:
  storageClassName: manual
  capacity:
    storage: 1Gi
  accessModes:
    - ReadWriteMany
  nfs:
    server: 10.244.1.4
    path: "/"

PVC描述的,则是Pod所希望使用的持久化存储的属性。比如,Volume存储的大小、可读写权限等等。

PVC对象通常由开发人员创建;或者以PVC模板的方式成为StatefulSet的一部分,然后由StatefulSet控制器负责创建带编号的PVC。

比如,开发人员可以声明一个1 GiB大小的PVC,如下所示:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: nfs
spec:
  accessModes:
    - ReadWriteMany
  storageClassName: manual
  resources:
    requests:
      storage: 1Gi

而用户创建的PVC要真正被容器使用起来,就必须先和某个符合条件的PV进行绑定。这里要检查的条件,包括两部分:

  • 第一个条件,当然是PV和PVC的spec字段。比如,PV的存储(storage)大小,就必须满足PVC的要求。
  • 而第二个条件,则是PV和PVC的storageClassName字段必须一样。这个机制我会在本篇文章的最后一部分专门介绍。

在成功地将PVC和PV进行绑定之后,Pod就能够像使用hostPath等常规类型的Volume一样,在自己的YAML文件里声明使用这个PVC了,如下所示:

apiVersion: v1
kind: Pod
metadata:
  labels:
    role: web-frontend
spec:
  containers:
  - name: web
    image: nginx
    ports:
      - name: web
        containerPort: 80
    volumeMounts:
        - name: nfs
          mountPath: "/usr/share/nginx/html"
  volumes:
  - name: nfs
    persistentVolumeClaim:
      claimName: nfs

可以看到,Pod需要做的,就是在volumes字段里声明自己要使用的PVC名字。接下来,等这个Pod创建之后,kubelet就会把这个PVC所对应的PV,也就是一个NFS类型的Volume,挂载在这个Pod容器内的目录上。

不难看出,PVC和PV的设计,其实跟“面向对象”的思想完全一致。

PVC可以理解为持久化存储的“接口”,它提供了对某种持久化存储的描述,但不提供具体的实现;而这个持久化存储的实现部分则由PV负责完成。

这样做的好处是,作为应用开发者,我们只需要跟PVC这个“接口”打交道,而不必关心具体的实现是NFS还是Ceph。毕竟这些存储相关的知识太专业了,应该交给专业的人去做。

而在上面的讲述中,其实还有一个比较棘手的情况。

比如,你在创建Pod的时候,系统里并没有合适的PV跟它定义的PVC绑定,也就是说此时容器想要使用的Volume不存在。这时候,Pod的启动就会报错。

但是,过了一会儿,运维人员也发现了这个情况,所以他赶紧创建了一个对应的PV。这时候,我们当然希望Kubernetes能够再次完成PVC和PV的绑定操作,从而启动Pod。

所以在Kubernetes中,实际上存在着一个专门处理持久化存储的控制器,叫作Volume Controller。这个Volume Controller维护着多个控制循环,其中有一个循环,扮演的就是撮合PV和PVC的“红娘”的角色。它的名字叫作PersistentVolumeController。

PersistentVolumeController会不断地查看当前每一个PVC,是不是已经处于Bound(已绑定)状态。如果不是,那它就会遍历所有的、可用的PV,并尝试将其与这个“单身”的PVC进行绑定。这样,Kubernetes就可以保证用户提交的每一个PVC,只要有合适的PV出现,它就能够很快进入绑定状态,从而结束“单身”之旅。

而所谓将一个PV与PVC进行“绑定”,其实就是将这个PV对象的名字,填在了PVC对象的spec.volumeName字段上。所以,接下来Kubernetes只要获取到这个PVC对象,就一定能够找到它所绑定的PV。

那么,这个PV对象,又是如何变成容器里的一个持久化存储的呢?

我在前面讲解容器基础的时候,已经为你详细剖析了容器Volume的挂载机制。用一句话总结,所谓容器的Volume,其实就是将一个宿主机上的目录,跟一个容器里的目录绑定挂载在了一起。(你可以借此机会,再回顾一下专栏的第8篇文章《白话容器基础(四):重新认识Docker容器》中的相关内容)

而所谓的“持久化Volume”,指的就是这个宿主机上的目录,具备“持久性”。即:这个目录里面的内容,既不会因为容器的删除而被清理掉,也不会跟当前的宿主机绑定。这样,当容器被重启或者在其他节点上重建出来之后,它仍然能够通过挂载这个Volume,访问到这些内容。

显然,我们前面使用的hostPath和emptyDir类型的Volume并不具备这个特征:它们既有可能被kubelet清理掉,也不能被“迁移”到其他节点上。

所以,大多数情况下,持久化Volume的实现,往往依赖于一个远程存储服务,比如:远程文件存储(比如,NFS、GlusterFS)、远程块存储(比如,公有云提供的远程磁盘)等等。

而Kubernetes需要做的工作,就是使用这些存储服务,来为容器准备一个持久化的宿主机目录,以供将来进行绑定挂载时使用。而所谓“持久化”,指的是容器在这个目录里写入的文件,都会保存在远程存储中,从而使得这个目录具备了“持久性”。

这个准备“持久化”宿主机目录的过程,我们可以形象地称为“两阶段处理”。

接下来,我通过一个具体的例子为你说明。

当一个Pod调度到一个节点上之后,kubelet就要负责为这个Pod创建它的Volume目录。默认情况下,kubelet为Volume创建的目录是如下所示的一个宿主机上的路径:

/var/lib/kubelet/pods/<Pod的ID>/volumes/kubernetes.io~<Volume类型>/<Volume名字>

接下来,kubelet要做的操作就取决于你的Volume类型了。

如果你的Volume类型是远程块存储,比如Google Cloud的Persistent Disk(GCE提供的远程磁盘服务),那么kubelet就需要先调用Goolge Cloud的API,将它所提供的Persistent Disk挂载到Pod所在的宿主机上。

备注:你如果不太了解块存储的话,可以直接把它理解为:一块磁盘

这相当于执行:

$ gcloud compute instances attach-disk <虚拟机名字> --disk <远程磁盘名字>

这一步为虚拟机挂载远程磁盘的操作,对应的正是“两阶段处理”的第一阶段。在Kubernetes中,我们把这个阶段称为Attach。

Attach阶段完成后,为了能够使用这个远程磁盘,kubelet还要进行第二个操作,即:格式化这个磁盘设备,然后将它挂载到宿主机指定的挂载点上。不难理解,这个挂载点,正是我在前面反复提到的Volume的宿主机目录。所以,这一步相当于执行:

# 通过lsblk命令获取磁盘设备ID
$ sudo lsblk
# 格式化成ext4格式
$ sudo mkfs.ext4 -m 0 -F -E lazy_itable_init=0,lazy_journal_init=0,discard /dev/<磁盘设备ID>
# 挂载到挂载点
$ sudo mkdir -p /var/lib/kubelet/pods/<Pod的ID>/volumes/kubernetes.io~<Volume类型>/<Volume名字>

这个将磁盘设备格式化并挂载到Volume宿主机目录的操作,对应的正是“两阶段处理”的第二个阶段,我们一般称为:Mount。

Mount阶段完成后,这个Volume的宿主机目录就是一个“持久化”的目录了,容器在它里面写入的内容,会保存在Google Cloud的远程磁盘中。

而如果你的Volume类型是远程文件存储(比如NFS)的话,kubelet的处理过程就会更简单一些。

因为在这种情况下,kubelet可以跳过“第一阶段”(Attach)的操作,这是因为一般来说,远程文件存储并没有一个“存储设备”需要挂载在宿主机上。

所以,kubelet会直接从“第二阶段”(Mount)开始准备宿主机上的Volume目录。

在这一步,kubelet需要作为client,将远端NFS服务器的目录(比如:“/”目录),挂载到Volume的宿主机目录上,即相当于执行如下所示的命令:

$ mount -t nfs <NFS服务器地址>:/ /var/lib/kubelet/pods/<Pod的ID>/volumes/kubernetes.io~<Volume类型>/<Volume名字>

通过这个挂载操作,Volume的宿主机目录就成为了一个远程NFS目录的挂载点,后面你在这个目录里写入的所有文件,都会被保存在远程NFS服务器上。所以,我们也就完成了对这个Volume宿主机目录的“持久化”。

到这里,你可能会有疑问,Kubernetes又是如何定义和区分这两个阶段的呢?

其实很简单,在具体的Volume插件的实现接口上,Kubernetes分别给这两个阶段提供了两种不同的参数列表:

  • 对于“第一阶段”(Attach),Kubernetes提供的可用参数是nodeName,即宿主机的名字。
  • 而对于“第二阶段”(Mount),Kubernetes提供的可用参数是dir,即Volume的宿主机目录。

所以,作为一个存储插件,你只需要根据自己的需求进行选择和实现即可。在后面关于编写存储插件的文章中,我会对这个过程做深入讲解。

而经过了“两阶段处理”,我们就得到了一个“持久化”的Volume宿主机目录。所以,接下来,kubelet只要把这个Volume目录通过CRI里的Mounts参数,传递给Docker,然后就可以为Pod里的容器挂载这个“持久化”的Volume了。其实,这一步相当于执行了如下所示的命令:

$ docker run -v /var/lib/kubelet/pods/<Pod的ID>/volumes/kubernetes.io~<Volume类型>/<Volume名字>:/<容器内的目标目录> 我的镜像 ...

以上,就是Kubernetes处理PV的具体原理了。

备注:对应地,在删除一个PV的时候,Kubernetes也需要Unmount和Dettach两个阶段来处理。这个过程我就不再详细介绍了,执行“反向操作”即可。

实际上,你可能已经发现,这个PV的处理流程似乎跟Pod以及容器的启动流程没有太多的耦合,只要kubelet在向Docker发起CRI请求之前,确保“持久化”的宿主机目录已经处理完毕即可。

所以,在Kubernetes中,上述关于PV的“两阶段处理”流程,是靠独立于kubelet主控制循环(Kubelet Sync Loop)之外的两个控制循环来实现的。

其中,“第一阶段”的Attach(以及Dettach)操作,是由Volume Controller负责维护的,这个控制循环的名字叫作:AttachDetachController。而它的作用,就是不断地检查每一个Pod对应的PV,和这个Pod所在宿主机之间挂载情况。从而决定,是否需要对这个PV进行Attach(或者Dettach)操作。

需要注意,作为一个Kubernetes内置的控制器,Volume Controller自然是kube-controller-manager的一部分。所以,AttachDetachController也一定是运行在Master节点上的。当然,Attach操作只需要调用公有云或者具体存储项目的API,并不需要在具体的宿主机上执行操作,所以这个设计没有任何问题。

而“第二阶段”的Mount(以及Unmount)操作,必须发生在Pod对应的宿主机上,所以它必须是kubelet组件的一部分。这个控制循环的名字,叫作:VolumeManagerReconciler,它运行起来之后,是一个独立于kubelet主循环的Goroutine。

通过这样将Volume的处理同kubelet的主循环解耦,Kubernetes就避免了这些耗时的远程挂载操作拖慢kubelet的主控制循环,进而导致Pod的创建效率大幅下降的问题。实际上,kubelet的一个主要设计原则,就是它的主控制循环绝对不可以被block。这个思想,我在后续的讲述容器运行时的时候还会提到。

在了解了Kubernetes的Volume处理机制之后,我再来为你介绍这个体系里最后一个重要概念:StorageClass。

我在前面介绍PV和PVC的时候,曾经提到过,PV这个对象的创建,是由运维人员完成的。但是,在大规模的生产环境里,这其实是一个非常麻烦的工作。

这是因为,一个大规模的Kubernetes集群里很可能有成千上万个PVC,这就意味着运维人员必须得事先创建出成千上万个PV。更麻烦的是,随着新的PVC不断被提交,运维人员就不得不继续添加新的、能满足条件的PV,否则新的Pod就会因为PVC绑定不到PV而失败。在实际操作中,这几乎没办法靠人工做到。

所以,Kubernetes为我们提供了一套可以自动创建PV的机制,即:Dynamic Provisioning。

相比之下,前面人工管理PV的方式就叫作Static Provisioning。

Dynamic Provisioning机制工作的核心,在于一个名叫StorageClass的API对象。

而StorageClass对象的作用,其实就是创建PV的模板。

具体地说,StorageClass对象会定义如下两个部分内容:

  • 第一,PV的属性。比如,存储类型、Volume的大小等等。
  • 第二,创建这种PV需要用到的存储插件。比如,Ceph等等。

有了这样两个信息之后,Kubernetes就能够根据用户提交的PVC,找到一个对应的StorageClass了。然后,Kubernetes就会调用该StorageClass声明的存储插件,创建出需要的PV。

举个例子,假如我们的Volume的类型是GCE的Persistent Disk的话,运维人员就需要定义一个如下所示的StorageClass:

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: block-service
provisioner: kubernetes.io/gce-pd
parameters:
  type: pd-ssd

在这个YAML文件里,我们定义了一个名叫block-service的StorageClass。

这个StorageClass的provisioner字段的值是:kubernetes.io/gce-pd,这正是Kubernetes内置的GCE PD存储插件的名字。

而这个StorageClass的parameters字段,就是PV的参数。比如:上面例子里的type=pd-ssd,指的是这个PV的类型是“SSD格式的GCE远程磁盘”。

需要注意的是,由于需要使用GCE Persistent Disk,上面这个例子只有在GCE提供的Kubernetes服务里才能实践。如果你想使用我们之前部署在本地的Kubernetes集群以及Rook存储服务的话,你的StorageClass需要使用如下所示的YAML文件来定义:

apiVersion: ceph.rook.io/v1beta1
kind: Pool
metadata:
  name: replicapool
  namespace: rook-ceph
spec:
  replicated:
    size: 3
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: block-service
provisioner: ceph.rook.io/block
parameters:
  pool: replicapool
  #The value of "clusterNamespace" MUST be the same as the one in which your rook cluster exist
  clusterNamespace: rook-ceph

在这个YAML文件中,我们定义的还是一个名叫block-service的StorageClass,只不过它声明使的存储插件是由Rook项目。

有了StorageClass的YAML文件之后,运维人员就可以在Kubernetes里创建这个StorageClass了:

$ kubectl create -f sc.yaml

这时候,作为应用开发者,我们只需要在PVC里指定要使用的StorageClass名字即可,如下所示:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: claim1
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: block-service
  resources:
    requests:
      storage: 30Gi

可以看到,我们在这个PVC里添加了一个叫作storageClassName的字段,用于指定该PVC所要使用的StorageClass的名字是:block-service。

以Google Cloud为例。

当我们通过kubectl create创建上述PVC对象之后,Kubernetes就会调用Google Cloud的API,创建出一块SSD格式的Persistent Disk。然后,再使用这个Persistent Disk的信息,自动创建出一个对应的PV对象。

我们可以一起来实践一下这个过程(如果使用Rook的话下面的流程也是一样的,只不过Rook创建出的是Ceph类型的PV):

$ kubectl create -f pvc.yaml

可以看到,我们创建的PVC会绑定一个Kubernetes自动创建的PV,如下所示:

$ kubectl describe pvc claim1
Name:           claim1
Namespace:      default
StorageClass:   block-service
Status:         Bound
Volume:         pvc-e5578707-c626-11e6-baf6-08002729a32b
Labels:         <none>
Capacity:       30Gi
Access Modes:   RWO
No Events.

而且,通过查看这个自动创建的PV的属性,你就可以看到它跟我们在PVC里声明的存储的属性是一致的,如下所示:

$ kubectl describe pv pvc-e5578707-c626-11e6-baf6-08002729a32b
Name:            pvc-e5578707-c626-11e6-baf6-08002729a32b
Labels:          <none>
StorageClass:    block-service
Status:          Bound
Claim:           default/claim1
Reclaim Policy:  Delete
Access Modes:    RWO
Capacity:        30Gi
...
No events.

此外,你还可以看到,这个自动创建出来的PV的StorageClass字段的值,也是block-service。这是因为,Kubernetes只会将StorageClass相同的PVC和PV绑定起来。

有了Dynamic Provisioning机制,运维人员只需要在Kubernetes集群里创建出数量有限的StorageClass对象就可以了。这就好比,运维人员在Kubernetes集群里创建出了各种各样的PV模板。这时候,当开发人员提交了包含StorageClass字段的PVC之后,Kubernetes就会根据这个StorageClass创建出对应的PV。

Kubernetes的官方文档里已经列出了默认支持Dynamic Provisioning的内置存储插件。而对于不在文档里的插件,比如NFS,或者其他非内置存储插件,你其实可以通过kubernetes-incubator/external-storage这个库来自己编写一个外部插件完成这个工作。像我们之前部署的Rook,已经内置了external-storage的实现,所以Rook是完全支持Dynamic Provisioning特性的。

需要注意的是,StorageClass并不是专门为了Dynamic Provisioning而设计的。

比如,在本篇一开始的例子里,我在PV和PVC里都声明了storageClassName=manual。而我的集群里,实际上并没有一个名叫manual的StorageClass对象。这完全没有问题,这个时候Kubernetes进行的是Static Provisioning,但在做绑定决策的时候,它依然会考虑PV和PVC的StorageClass定义。

而这么做的好处也很明显:这个PVC和PV的绑定关系,就完全在我自己的掌控之中。

这里,你可能会有疑问,我在之前讲解StatefulSet存储状态的例子时,好像并没有声明StorageClass啊?

实际上,如果你的集群已经开启了名叫DefaultStorageClass的Admission Plugin,它就会为PVC和PV自动添加一个默认的StorageClass;否则,PVC的storageClassName的值就是“”,这也意味着它只能够跟storageClassName也是“”的PV进行绑定。

总结

在今天的分享中,我为你详细解释了PVC和PV的设计与实现原理,并为你阐述了StorageClass到底是干什么用的。这些概念之间的关系,可以用如下所示的一幅示意图描述:


从图中我们可以看到,在这个体系中:

  • PVC描述的,是Pod想要使用的持久化存储的属性,比如存储的大小、读写权限等。

  • PV描述的,则是一个具体的Volume的属性,比如Volume的类型、挂载目录、远程存储服务器地址等。

  • 而StorageClass的作用,则是充当PV的模板。并且,只有同属于一个StorageClass的PV和PVC,才可以绑定在一起。

当然,StorageClass的另一个重要作用,是指定PV的Provisioner(存储插件)。这时候,如果你的存储插件支持Dynamic Provisioning的话,Kubernetes就可以自动为你创建PV了。

基于上述讲述,为了统一概念和方便叙述,在本专栏中,我以后凡是提到“Volume”,指的就是一个远程存储服务挂载在宿主机上的持久化目录;而“PV”,指的是这个Volume在Kubernetes里的API对象。

需要注意的是,这套容器持久化存储体系,完全是Kubernetes项目自己负责管理的,并不依赖于docker volume命令和Docker的存储插件。当然,这套体系本身就比docker volume命令的诞生时间还要早得多。

思考题

在了解了PV、PVC的设计和实现原理之后,你是否依然觉得它有“过度设计”的嫌疑?或者,你是否有更加简单、足以解决你90%需求的Volume的用法?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

29-PV、PVC体系是不是多此一举?从本地持久化卷谈起

你好,我是张磊。今天我和你分享的主题是:PV、PVC体系是不是多此一举?从本地持久化卷谈起。

在上一篇文章中,我为你详细讲解了PV、PVC持久化存储体系在Kubernetes项目中的设计和实现原理。而在文章最后的思考题中,我为你留下了这样一个讨论话题:像PV、PVC这样的用法,是不是有“过度设计”的嫌疑?

比如,我们公司的运维人员可以像往常一样维护一套NFS或者Ceph服务器,根本不必学习Kubernetes。而开发人员,则完全可以靠“复制粘贴”的方式,在Pod的YAML文件里填上Volumes字段,而不需要去使用PV和PVC。

实际上,如果只是为了职责划分,PV、PVC体系确实不见得比直接在Pod里声明Volumes字段有什么优势。

不过,你有没有想过这样一个问题,如果Kubernetes内置的20种持久化数据卷实现,都没办法满足你的容器存储需求时,该怎么办?

这个情况乍一听起来有点不可思议。但实际上,凡是鼓捣过开源项目的读者应该都有所体会,“不能用”“不好用”“需要定制开发”,这才是落地开源基础设施项目的三大常态。

而在持久化存储领域,用户呼声最高的定制化需求,莫过于支持“本地”持久化存储了。

也就是说,用户希望Kubernetes能够直接使用宿主机上的本地磁盘目录,而不依赖于远程存储服务,来提供“持久化”的容器Volume。

这样做的好处很明显,由于这个Volume直接使用的是本地磁盘,尤其是SSD盘,它的读写性能相比于大多数远程存储来说,要好得多。这个需求对本地物理服务器部署的私有Kubernetes集群来说,非常常见。

所以,Kubernetes在v1.10之后,就逐渐依靠PV、PVC体系实现了这个特性。这个特性的名字叫作:Local Persistent Volume。

不过,首先需要明确的是,Local Persistent Volume并不适用于所有应用。事实上,它的适用范围非常固定,比如:高优先级的系统应用,需要在多个不同节点上存储数据,并且对I/O较为敏感。典型的应用包括:分布式数据存储比如MongoDB、Cassandra等,分布式文件系统比如GlusterFS、Ceph等,以及需要在本地磁盘上进行大量数据缓存的分布式应用。

其次,相比于正常的PV,一旦这些节点宕机且不能恢复时,Local Persistent Volume的数据就可能丢失。这就要求使用Local Persistent Volume的应用必须具备数据备份和恢复的能力,允许你把这些数据定时备份在其他位置。

接下来,我就为你深入讲解一下这个特性。

不难想象,Local Persistent Volume的设计,主要面临两个难点。

第一个难点在于:如何把本地磁盘抽象成PV。

可能你会说,Local Persistent Volume,不就等同于hostPath加NodeAffinity吗?

比如,一个Pod可以声明使用类型为Local的PV,而这个PV其实就是一个hostPath类型的Volume。如果这个hostPath对应的目录,已经在节点A上被事先创建好了。那么,我只需要再给这个Pod加上一个nodeAffinity=nodeA,不就可以使用这个Volume了吗?

事实上,你绝不应该把一个宿主机上的目录当作PV使用。这是因为,这种本地目录的存储行为完全不可控,它所在的磁盘随时都可能被应用写满,甚至造成整个宿主机宕机。而且,不同的本地目录之间也缺乏哪怕最基础的I/O隔离机制。

所以,一个Local Persistent Volume对应的存储介质,一定是一块额外挂载在宿主机的磁盘或者块设备(“额外”的意思是,它不应该是宿主机根目录所使用的主硬盘)。这个原则,我们可以称为“一个PV一块盘”。

第二个难点在于:调度器如何保证Pod始终能被正确地调度到它所请求的Local Persistent Volume所在的节点上呢?

造成这个问题的原因在于,对于常规的PV来说,Kubernetes都是先调度Pod到某个节点上,然后,再通过“两阶段处理”来“持久化”这台机器上的Volume目录,进而完成Volume目录与容器的绑定挂载。

可是,对于Local PV来说,节点上可供使用的磁盘(或者块设备),必须是运维人员提前准备好的。它们在不同节点上的挂载情况可以完全不同,甚至有的节点可以没这种磁盘。

所以,这时候,调度器就必须能够知道所有节点与Local Persistent Volume对应的磁盘的关联关系,然后根据这个信息来调度Pod。

这个原则,我们可以称为“在调度的时候考虑Volume分布”。在Kubernetes的调度器里,有一个叫作VolumeBindingChecker的过滤条件专门负责这个事情。在Kubernetes v1.11中,这个过滤条件已经默认开启了。

基于上述讲述,在开始使用Local Persistent Volume之前,你首先需要在集群里配置好磁盘或者块设备。在公有云上,这个操作等同于给虚拟机额外挂载一个磁盘,比如GCE的Local SSD类型的磁盘就是一个典型例子。

而在我们部署的私有环境中,你有两种办法来完成这个步骤。

  • 第一种,当然就是给你的宿主机挂载并格式化一个可用的本地磁盘,这也是最常规的操作;
  • 第二种,对于实验环境,你其实可以在宿主机上挂载几个RAM Disk(内存盘)来模拟本地磁盘。

接下来,我会使用第二种方法,在我们之前部署的Kubernetes集群上进行实践。

首先,在名叫node-1的宿主机上创建一个挂载点,比如/mnt/disks;然后,用几个RAM Disk来模拟本地磁盘,如下所示:

# 在node-1上执行
$ mkdir /mnt/disks
$ for vol in vol1 vol2 vol3; do
    mkdir /mnt/disks/$vol
    mount -t tmpfs $vol /mnt/disks/$vol
done

需要注意的是,如果你希望其他节点也能支持Local Persistent Volume的话,那就需要为它们也执行上述操作,并且确保这些磁盘的名字(vol1、vol2等)都不重复。

接下来,我们就可以为这些本地磁盘定义对应的PV了,如下所示:

apiVersion: v1
kind: PersistentVolume
metadata:
  name: example-pv
spec:
  capacity:
    storage: 5Gi
  volumeMode: Filesystem
  accessModes:
  - ReadWriteOnce
  persistentVolumeReclaimPolicy: Delete
  storageClassName: local-storage
  local:
    path: /mnt/disks/vol1
  nodeAffinity:
    required:
      nodeSelectorTerms:
      - matchExpressions:
        - key: kubernetes.io/hostname
          operator: In
          values:
          - node-1

可以看到,这个PV的定义里:local字段,指定了它是一个Local Persistent Volume;而path字段,指定的正是这个PV对应的本地磁盘的路径,即:/mnt/disks/vol1。

当然了,这也就意味着如果Pod要想使用这个PV,那它就必须运行在node-1上。所以,在这个PV的定义里,需要有一个nodeAffinity字段指定node-1这个节点的名字。这样,调度器在调度Pod的时候,就能够知道一个PV与节点的对应关系,从而做出正确的选择。这正是Kubernetes实现“在调度的时候就考虑Volume分布”的主要方法。

接下来,我们就可以使用kubect create来创建这个PV,如下所示:

$ kubectl create -f local-pv.yaml
persistentvolume/example-pv created

$ kubectl get pv
NAME         CAPACITY   ACCESS MODES   RECLAIM POLICY  STATUS      CLAIM             STORAGECLASS    REASON    AGE
example-pv   5Gi        RWO            Delete           Available                     local-storage             16s

可以看到,这个PV创建后,进入了Available(可用)状态。

而正如我在上一篇文章里所建议的那样,使用PV和PVC的最佳实践,是你要创建一个StorageClass来描述这个PV,如下所示:

kind: StorageClass
apiVersion: storage.k8s.io/v1
metadata:
  name: local-storage
provisioner: kubernetes.io/no-provisioner
volumeBindingMode: WaitForFirstConsumer

这个StorageClass的名字,叫作local-storage。需要注意的是,在它的provisioner字段,我们指定的是no-provisioner。这是因为Local Persistent Volume目前尚不支持Dynamic Provisioning,所以它没办法在用户创建PVC的时候,就自动创建出对应的PV。也就是说,我们前面创建PV的操作,是不可以省略的。

与此同时,这个StorageClass还定义了一个volumeBindingMode=WaitForFirstConsumer的属性。它是Local Persistent Volume里一个非常重要的特性,即:延迟绑定

我们知道,当你提交了PV和PVC的YAML文件之后,Kubernetes就会根据它们俩的属性,以及它们指定的StorageClass来进行绑定。只有绑定成功后,Pod才能通过声明这个PVC来使用对应的PV。

可是,如果你使用的是Local Persistent Volume的话,就会发现,这个流程根本行不通。

比如,现在你有一个Pod,它声明使用的PVC叫作pvc-1。并且,我们规定,这个Pod只能运行在node-2上。

而在Kubernetes集群中,有两个属性(比如:大小、读写权限)相同的Local类型的PV。

其中,第一个PV的名字叫作pv-1,它对应的磁盘所在的节点是node-1。而第二个PV的名字叫作pv-2,它对应的磁盘所在的节点是node-2。

假设现在,Kubernetes的Volume控制循环里,首先检查到了pvc-1和pv-1的属性是匹配的,于是就将它们俩绑定在一起。

然后,你用kubectl create创建了这个Pod。

这时候,问题就出现了。

调度器看到,这个Pod所声明的pvc-1已经绑定了pv-1,而pv-1所在的节点是node-1,根据“调度器必须在调度的时候考虑Volume分布”的原则,这个Pod自然会被调度到node-1上。

可是,我们前面已经规定过,这个Pod根本不允许运行在node-1上。所以。最后的结果就是,这个Pod的调度必然会失败。

这就是为什么,在使用Local Persistent Volume的时候,我们必须想办法推迟这个“绑定”操作。

那么,具体推迟到什么时候呢?

答案是:推迟到调度的时候。

所以说,StorageClass里的volumeBindingMode=WaitForFirstConsumer的含义,就是告诉Kubernetes里的Volume控制循环(“红娘”):虽然你已经发现这个StorageClass关联的PVC与PV可以绑定在一起,但请不要现在就执行绑定操作(即:设置PVC的VolumeName字段)。

而要等到第一个声明使用该PVC的Pod出现在调度器之后,调度器再综合考虑所有的调度规则,当然也包括每个PV所在的节点位置,来统一决定,这个Pod声明的PVC,到底应该跟哪个PV进行绑定。

这样,在上面的例子里,由于这个Pod不允许运行在pv-1所在的节点node-1,所以它的PVC最后会跟pv-2绑定,并且Pod也会被调度到node-2上。

所以,通过这个延迟绑定机制,原本实时发生的PVC和PV的绑定过程,就被延迟到了Pod第一次调度的时候在调度器中进行,从而保证了这个绑定结果不会影响Pod的正常调度

当然,在具体实现中,调度器实际上维护了一个与Volume Controller类似的控制循环,专门负责为那些声明了“延迟绑定”的PV和PVC进行绑定工作。

通过这样的设计,这个额外的绑定操作,并不会拖慢调度器的性能。而当一个Pod的PVC尚未完成绑定时,调度器也不会等待,而是会直接把这个Pod重新放回到待调度队列,等到下一个调度周期再做处理。

在明白了这个机制之后,我们就可以创建StorageClass了,如下所示:

$ kubectl create -f local-sc.yaml
storageclass.storage.k8s.io/local-storage created

接下来,我们只需要定义一个非常普通的PVC,就可以让Pod使用到上面定义好的Local Persistent Volume了,如下所示:

kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: example-local-claim
spec:
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 5Gi
  storageClassName: local-storage

可以看到,这个PVC没有任何特别的地方。唯一需要注意的是,它声明的storageClassName是local-storage。所以,将来Kubernetes的Volume Controller看到这个PVC的时候,不会为它进行绑定操作。

现在,我们来创建这个PVC:

$ kubectl create -f local-pvc.yaml
persistentvolumeclaim/example-local-claim created

$ kubectl get pvc
NAME                  STATUS    VOLUME    CAPACITY   ACCESS MODES   STORAGECLASS    AGE
example-local-claim   Pending                                       local-storage   7s

可以看到,尽管这个时候,Kubernetes里已经存在了一个可以与PVC匹配的PV,但这个PVC依然处于Pending状态,也就是等待绑定的状态。

然后,我们编写一个Pod来声明使用这个PVC,如下所示:

kind: Pod
apiVersion: v1
metadata:
  name: example-pv-pod
spec:
  volumes:
    - name: example-pv-storage
      persistentVolumeClaim:
       claimName: example-local-claim
  containers:
    - name: example-pv-container
      image: nginx
      ports:
        - containerPort: 80
          name: "http-server"
      volumeMounts:
        - mountPath: "/usr/share/nginx/html"
          name: example-pv-storage

这个Pod没有任何特别的地方,你只需要注意,它的volumes字段声明要使用前面定义的、名叫example-local-claim的PVC即可。

而我们一旦使用kubectl create创建这个Pod,就会发现,我们前面定义的PVC,会立刻变成Bound状态,与前面定义的PV绑定在了一起,如下所示:

$ kubectl create -f local-pod.yaml
pod/example-pv-pod created

$ kubectl get pvc
NAME                  STATUS    VOLUME       CAPACITY   ACCESS MODES   STORAGECLASS    AGE
example-local-claim   Bound     example-pv   5Gi        RWO            local-storage   6h

也就是说,在我们创建的Pod进入调度器之后,“绑定”操作才开始进行。

这时候,我们可以尝试在这个Pod的Volume目录里,创建一个测试文件,比如:

$ kubectl exec -it example-pv-pod -- /bin/sh
# cd /usr/share/nginx/html
# touch test.txt

然后,登录到node-1这台机器上,查看一下它的 /mnt/disks/vol1目录下的内容,你就可以看到刚刚创建的这个文件:

# 在node-1上
$ ls /mnt/disks/vol1
test.txt

而如果你重新创建这个Pod的话,就会发现,我们之前创建的测试文件,依然被保存在这个持久化Volume当中:

$ kubectl delete -f local-pod.yaml

$ kubectl create -f local-pod.yaml

$ kubectl exec -it example-pv-pod -- /bin/sh
# ls /usr/share/nginx/html
# touch test.txt

这就说明,像Kubernetes这样构建出来的、基于本地存储的Volume,完全可以提供容器持久化存储的功能。所以,像StatefulSet这样的有状态编排工具,也完全可以通过声明Local类型的PV和PVC,来管理应用的存储状态。

需要注意的是,我们上面手动创建PV的方式,即Static的PV管理方式,在删除PV时需要按如下流程执行操作:

  1. 删除使用这个PV的Pod;

  2. 从宿主机移除本地磁盘(比如,umount它);

  3. 删除PVC;

  4. 删除PV。

如果不按照这个流程的话,这个PV的删除就会失败。

当然,由于上面这些创建PV和删除PV的操作比较繁琐,Kubernetes其实提供了一个Static Provisioner来帮助你管理这些PV。

比如,我们现在的所有磁盘,都挂载在宿主机的/mnt/disks目录下。

那么,当Static Provisioner启动后,它就会通过DaemonSet,自动检查每个宿主机的/mnt/disks目录。然后,调用Kubernetes API,为这些目录下面的每一个挂载,创建一个对应的PV对象出来。这些自动创建的PV,如下所示:

$ kubectl get pv
NAME                CAPACITY    ACCESSMODES   RECLAIMPOLICY   STATUS      CLAIM     STORAGECLASS    REASON    AGE
local-pv-ce05be60   1024220Ki   RWO           Delete          Available             local-storage             26s

$ kubectl describe pv local-pv-ce05be60
Name:  local-pv-ce05be60
...
StorageClass: local-storage
Status:  Available
Claim:  
Reclaim Policy: Delete
Access Modes: RWO
Capacity: 1024220Ki
NodeAffinity:
  Required Terms:
      Term 0:  kubernetes.io/hostname in [node-1]
Message:
Source:
    Type: LocalVolume (a persistent volume backed by local storage on a node)
    Path: /mnt/disks/vol1

这个PV里的各种定义,比如StorageClass的名字、本地磁盘挂载点的位置,都可以通过provisioner的配置文件指定。当然,provisioner也会负责前面提到的PV的删除工作。

而这个provisioner本身,其实也是一个我们前面提到过的External Provisioner,它的部署方法,在对应的文档里有详细描述。这部分内容,就留给你课后自行探索了。

总结

在今天这篇文章中,我为你详细介绍了Kubernetes里Local Persistent Volume的实现方式。

可以看到,正是通过PV和PVC,以及StorageClass这套存储体系,这个后来新添加的持久化存储方案,对Kubernetes已有用户的影响,几乎可以忽略不计。作为用户,你的Pod的YAML和PVC的YAML,并没有任何特殊的改变,这个特性所有的实现只会影响到PV的处理,也就是由运维人员负责的那部分工作。

而这,正是这套存储体系带来的“解耦”的好处。

其实,Kubernetes很多看起来比较“繁琐”的设计(比如“声明式API”,以及我今天讲解的“PV、PVC体系”)的主要目的,都是希望为开发者提供更多的“可扩展性”,给使用者带来更多的“稳定性”和“安全感”。这两个能力的高低,是衡量开源基础设施项目水平的重要标准。

思考题

正是由于需要使用“延迟绑定”这个特性,Local Persistent Volume目前还不能支持Dynamic Provisioning。你是否能说出,为什么“延迟绑定”会跟Dynamic Provisioning有冲突呢?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

30-编写自己的存储插件:FlexVolume与CSI

你好,我是张磊。今天我和你分享的主题是:编写自己的存储插件之FlexVolume与CSI。

在上一篇文章中,我为你详细介绍了Kubernetes里的持久化存储体系,讲解了PV和PVC的具体实现原理,并提到了这样的设计实际上是出于对整个存储体系的可扩展性的考虑。

而在今天这篇文章中,我就和你分享一下如何借助这些机制,来开发自己的存储插件。

在Kubernetes中,存储插件的开发有两种方式:FlexVolume和CSI。

接下来,我就先为你剖析一下Flexvolume的原理和使用方法

举个例子,现在我们要编写的是一个使用NFS实现的FlexVolume插件。

对于一个FlexVolume类型的PV来说,它的YAML文件如下所示:

apiVersion: v1
kind: PersistentVolume
metadata:
  name: pv-flex-nfs
spec:
  capacity:
    storage: 10Gi
  accessModes:
    - ReadWriteMany
  flexVolume:
    driver: "k8s/nfs"
    fsType: "nfs"
    options:
      server: "10.10.0.25" # 改成你自己的NFS服务器地址
      share: "export"

可以看到,这个PV定义的Volume类型是flexVolume。并且,我们指定了这个Volume的driver叫作k8s/nfs。这个名字很重要,我后面马上会为你解释它的含义。

而Volume的options字段,则是一个自定义字段。也就是说,它的类型,其实是map[string]string。所以,你可以在这一部分自由地加上你想要定义的参数。

在我们这个例子里,options字段指定了NFS服务器的地址(server: “10.10.0.25”),以及NFS共享目录的名字(share: “export”)。当然,你这里定义的所有参数,后面都会被FlexVolume拿到。

备注:你可以使用这个Docker镜像轻松地部署一个试验用的NFS服务器。

像这样的一个PV被创建后,一旦和某个PVC绑定起来,这个FlexVolume类型的Volume就会进入到我们前面讲解过的Volume处理流程。

你应该还记得,这个流程的名字叫作“两阶段处理”,即“Attach阶段”和“Mount阶段”。它们的主要作用,是在Pod所绑定的宿主机上,完成这个Volume目录的持久化过程,比如为虚拟机挂载磁盘(Attach),或者挂载一个NFS的共享目录(Mount)。

备注:你可以再回顾一下第28篇文章《PV、PVC、StorageClass,这些到底在说啥?》中的相关内容。

而在具体的控制循环中,这两个操作实际上调用的,正是Kubernetes的pkg/volume目录下的存储插件(Volume Plugin)。在我们这个例子里,就是pkg/volume/flexvolume这个目录里的代码。

当然了,这个目录其实只是FlexVolume插件的入口。以“Mount阶段”为例,在FlexVolume目录里,它的处理过程非常简单,如下所示:

// SetUpAt creates new directory.
func (f *flexVolumeMounter) SetUpAt(dir string, fsGroup *int64) error {
  ...
  call := f.plugin.NewDriverCall(mountCmd)
  
  // Interface parameters
  call.Append(dir)
  
  extraOptions := make(map[string]string)
  
  // pod metadata
  extraOptions[optionKeyPodName] = f.podName
  extraOptions[optionKeyPodNamespace] = f.podNamespace
  
  ...
  
  call.AppendSpec(f.spec, f.plugin.host, extraOptions)
  
  _, err = call.Run()
  
  ...
  
  return nil
}

上面这个名叫SetUpAt()的方法,正是FlexVolume插件对“Mount阶段”的实现位置。而SetUpAt()实际上只做了一件事,那就是封装出了一行命令(即:NewDriverCall),由kubelet在“Mount阶段”去执行。

在我们这个例子中,kubelet要通过插件在宿主机上执行的命令,如下所示

/usr/libexec/kubernetes/kubelet-plugins/volume/exec/k8s~nfs/nfs mount <mount dir> <json param>

其中,/usr/libexec/kubernetes/kubelet-plugins/volume/exec/k8s~nfs/nfs就是插件的可执行文件的路径。这个名叫nfs的文件,正是你要编写的插件的实现。它可以是一个二进制文件,也可以是一个脚本。总之,只要能在宿主机上被执行起来即可。

而且这个路径里的k8s~nfs部分,正是这个插件在Kubernetes里的名字。它是从driver="k8s/nfs"字段解析出来的。

这个driver字段的格式是:vendor/driver。比如,一家存储插件的提供商(vendor)的名字叫作k8s,提供的存储驱动(driver)是nfs,那么Kubernetes就会使用k8s~nfs来作为插件名。

所以说,当你编写完了FlexVolume的实现之后,一定要把它的可执行文件放在每个节点的插件目录下。

而紧跟在可执行文件后面的“mount”参数,定义的就是当前的操作。在FlexVolume里,这些操作参数的名字是固定的,比如init、mount、unmount、attach,以及dettach等等,分别对应不同的Volume处理操作。

而跟在mount参数后面的两个字段:<mount dir><json params>,则是FlexVolume必须提供给这条命令的两个执行参数。

其中第一个执行参数<mount dir>,正是kubelet调用SetUpAt()方法传递来的dir的值。它代表的是当前正在处理的Volume在宿主机上的目录。在我们的例子里,这个路径如下所示:

/var/lib/kubelet/pods/<Pod ID>/volumes/k8s~nfs/test

其中,test正是我们前面定义的PV的名字;而k8s~nfs,则是插件的名字。可以看到,插件的名字正是从你声明的driver="k8s/nfs"字段里解析出来的。

而第二个执行参数<json params>,则是一个JSON Map格式的参数列表。我们在前面PV里定义的options字段的值,都会被追加在这个参数里。此外,在SetUpAt()方法里可以看到,这个参数列表里还包括了Pod的名字、Namespace等元数据(Metadata)。

在明白了存储插件的调用方式和参数列表之后,这个插件的可执行文件的实现部分就非常容易理解了。

在这个例子中,我直接编写了一个简单的shell脚本来作为插件的实现,它对“Mount阶段”的处理过程,如下所示:

domount() {
 MNTPATH=$1
 
 NFS_SERVER=$(echo $2 | jq -r '.server')
 SHARE=$(echo $2 | jq -r '.share')
 
 ...
 
 mkdir -p ${MNTPATH} &> /dev/null
 
 mount -t nfs ${NFS_SERVER}:/${SHARE} ${MNTPATH} &> /dev/null
 if [ $? -ne 0 ]; then
  err "{ \"status\": \"Failure\", \"message\": \"Failed to mount ${NFS_SERVER}:${SHARE} at ${MNTPATH}\"}"
  exit 1
 fi
 log '{"status": "Success"}'
 exit 0
}

可以看到,当kubelet在宿主机上执行“nfs mount <mount dir> <json params>”的时候,这个名叫nfs的脚本,就可以直接从<mount dir>参数里拿到Volume在宿主机上的目录,即:MNTPATH=$1。而你在PV的options字段里定义的NFS的服务器地址(options.server)和共享目录名字(options.share),则可以从第二个<json params>参数里解析出来。这里,我们使用了jq命令,来进行解析工作。

有了这三个参数之后,这个脚本最关键的一步,当然就是执行:mount -t nfs ${NFS_SERVER}:/${SHARE} ${MNTPATH} 。这样,一个NFS的数据卷就被挂载到了MNTPATH,也就是Volume所在的宿主机目录上,一个持久化的Volume目录就处理完了。

需要注意的是,当这个mount -t nfs操作完成后,你必须把一个JOSN格式的字符串,比如:{“status”: “Success”},返回给调用者,也就是kubelet。这是kubelet判断这次调用是否成功的唯一依据。

综上所述,在“Mount阶段”,kubelet的VolumeManagerReconcile控制循环里的一次“调谐”操作的执行流程,如下所示:

kubelet --> pkg/volume/flexvolume.SetUpAt() --> /usr/libexec/kubernetes/kubelet-plugins/volume/exec/k8s~nfs/nfs mount <mount dir> <json param>

备注:这个NFS的FlexVolume的完整实现,在这个GitHub库里。而你如果想用Go语言编写FlexVolume的话,我也有一个很好的例子供你参考。

当然,在前面文章中我也提到过,像NFS这样的文件系统存储,并不需要在宿主机上挂载磁盘或者块设备。所以,我们也就不需要实现attach和dettach操作了。

不过,像这样的FlexVolume实现方式,虽然简单,但局限性却很大。

比如,跟Kubernetes内置的NFS插件类似,这个NFS FlexVolume插件,也不能支持Dynamic Provisioning(即:为每个PVC自动创建PV和对应的Volume)。除非你再为它编写一个专门的External Provisioner。

再比如,我的插件在执行mount操作的时候,可能会生成一些挂载信息。这些信息,在后面执行unmount操作的时候会被用到。可是,在上述FlexVolume的实现里,你没办法把这些信息保存在一个变量里,等到unmount的时候直接使用。

这个原因也很容易理解:FlexVolume每一次对插件可执行文件的调用,都是一次完全独立的操作。所以,我们只能把这些信息写在一个宿主机上的临时文件里,等到unmount的时候再去读取。

这也是为什么,我们需要有Container Storage Interface(CSI)这样更完善、更编程友好的插件方式。

接下来,我就来为你讲解一下开发存储插件的第二种方式CSI。我们先来看一下CSI插件体系的设计原理

其实,通过前面对FlexVolume的讲述,你应该可以明白,默认情况下,Kubernetes里通过存储插件管理容器持久化存储的原理,可以用如下所示的示意图来描述:


可以看到,在上述体系下,无论是FlexVolume,还是Kubernetes内置的其他存储插件,它们实际上担任的角色,仅仅是Volume管理中的“Attach阶段”和“Mount阶段”的具体执行者。而像Dynamic Provisioning这样的功能,就不是存储插件的责任,而是Kubernetes本身存储管理功能的一部分。

相比之下,CSI插件体系的设计思想,就是把这个Provision阶段,以及Kubernetes里的一部分存储管理功能,从主干代码里剥离出来,做成了几个单独的组件。这些组件会通过Watch API监听Kubernetes里与存储相关的事件变化,比如PVC的创建,来执行具体的存储管理动作。

而这些管理动作,比如“Attach阶段”和“Mount阶段”的具体操作,实际上就是通过调用CSI插件来完成的。

这种设计思路,我可以用如下所示的一幅示意图来表示:

可以看到,这套存储插件体系多了三个独立的外部组件(External Components),即:Driver Registrar、External Provisioner和External Attacher,对应的正是从Kubernetes项目里面剥离出来的那部分存储管理功能。

需要注意的是,External Components虽然是外部组件,但依然由Kubernetes社区来开发和维护。

而图中最右侧的部分,就是需要我们编写代码来实现的CSI插件。一个CSI插件只有一个二进制文件,但它会以gRPC的方式对外提供三个服务(gRPC Service),分别叫作:CSI Identity、CSI Controller和CSI Node。

我先来为你讲解一下这三个External Components

其中,Driver Registrar组件,负责将插件注册到kubelet里面(这可以类比为,将可执行文件放在插件目录下)。而在具体实现上,Driver Registrar需要请求CSI插件的Identity服务来获取插件信息。

External Provisioner组件,负责的正是Provision阶段。在具体实现上,External Provisioner监听(Watch)了APIServer里的PVC对象。当一个PVC被创建时,它就会调用CSI Controller的CreateVolume方法,为你创建对应PV。

此外,如果你使用的存储是公有云提供的磁盘(或者块设备)的话,这一步就需要调用公有云(或者块设备服务)的API来创建这个PV所描述的磁盘(或者块设备)了。

不过,由于CSI插件是独立于Kubernetes之外的,所以在CSI的API里不会直接使用Kubernetes定义的PV类型,而是会自己定义一个单独的Volume类型。

为了方便叙述,在本专栏里,我会把Kubernetes里的持久化卷类型叫作PV,把CSI里的持久化卷类型叫作CSI Volume,请你务必区分清楚。

最后一个External Attacher组件,负责的正是“Attach阶段”。在具体实现上,它监听了APIServer里VolumeAttachment对象的变化。VolumeAttachment对象是Kubernetes确认一个Volume可以进入“Attach阶段”的重要标志,我会在下一篇文章里为你详细讲解。

一旦出现了VolumeAttachment对象,External Attacher就会调用CSI Controller服务的ControllerPublish方法,完成它所对应的Volume的Attach阶段。

而Volume的“Mount阶段”,并不属于External Components的职责。当kubelet的VolumeManagerReconciler控制循环检查到它需要执行Mount操作的时候,会通过pkg/volume/csi包,直接调用CSI Node服务完成Volume的“Mount阶段”。

在实际使用CSI插件的时候,我们会将这三个External Components作为sidecar容器和CSI插件放置在同一个Pod中。由于External Components对CSI插件的调用非常频繁,所以这种sidecar的部署方式非常高效。

接下来,我再为你讲解一下CSI插件的里三个服务:CSI Identity、CSI Controller和CSI Node。

其中,CSI插件的CSI Identity服务,负责对外暴露这个插件本身的信息,如下所示:

service Identity {
  // return the version and name of the plugin
  rpc GetPluginInfo(GetPluginInfoRequest)
    returns (GetPluginInfoResponse) {}
  // reports whether the plugin has the ability of serving the Controller interface
  rpc GetPluginCapabilities(GetPluginCapabilitiesRequest)
    returns (GetPluginCapabilitiesResponse) {}
  // called by the CO just to check whether the plugin is running or not
  rpc Probe (ProbeRequest)
    returns (ProbeResponse) {}
}

CSI Controller服务,定义的则是对CSI Volume(对应Kubernetes里的PV)的管理接口,比如:创建和删除CSI Volume、对CSI Volume进行Attach/Dettach(在CSI里,这个操作被叫作Publish/Unpublish),以及对CSI Volume进行Snapshot等,它们的接口定义如下所示:

service Controller {
  // provisions a volume
  rpc CreateVolume (CreateVolumeRequest)
    returns (CreateVolumeResponse) {}
    
  // deletes a previously provisioned volume
  rpc DeleteVolume (DeleteVolumeRequest)
    returns (DeleteVolumeResponse) {}
    
  // make a volume available on some required node
  rpc ControllerPublishVolume (ControllerPublishVolumeRequest)
    returns (ControllerPublishVolumeResponse) {}
    
  // make a volume un-available on some required node
  rpc ControllerUnpublishVolume (ControllerUnpublishVolumeRequest)
    returns (ControllerUnpublishVolumeResponse) {}
    
  ...
  
  // make a snapshot
  rpc CreateSnapshot (CreateSnapshotRequest)
    returns (CreateSnapshotResponse) {}
    
  // Delete a given snapshot
  rpc DeleteSnapshot (DeleteSnapshotRequest)
    returns (DeleteSnapshotResponse) {}
    
  ...
}

不难发现,CSI Controller服务里定义的这些操作有个共同特点,那就是它们都无需在宿主机上进行,而是属于Kubernetes里Volume Controller的逻辑,也就是属于Master节点的一部分。

需要注意的是,正如我在前面提到的那样,CSI Controller服务的实际调用者,并不是Kubernetes(即:通过pkg/volume/csi发起CSI请求),而是External Provisioner和External Attacher。这两个External Components,分别通过监听 PVC和VolumeAttachement对象,来跟Kubernetes进行协作。

而CSI Volume需要在宿主机上执行的操作,都定义在了CSI Node服务里面,如下所示:

service Node {
  // temporarily mount the volume to a staging path
  rpc NodeStageVolume (NodeStageVolumeRequest)
    returns (NodeStageVolumeResponse) {}
    
  // unmount the volume from staging path
  rpc NodeUnstageVolume (NodeUnstageVolumeRequest)
    returns (NodeUnstageVolumeResponse) {}
    
  // mount the volume from staging to target path
  rpc NodePublishVolume (NodePublishVolumeRequest)
    returns (NodePublishVolumeResponse) {}
    
  // unmount the volume from staging path
  rpc NodeUnpublishVolume (NodeUnpublishVolumeRequest)
    returns (NodeUnpublishVolumeResponse) {}
    
  // stats for the volume
  rpc NodeGetVolumeStats (NodeGetVolumeStatsRequest)
    returns (NodeGetVolumeStatsResponse) {}
    
  ...
  
  // Similar to NodeGetId
  rpc NodeGetInfo (NodeGetInfoRequest)
    returns (NodeGetInfoResponse) {}
}

需要注意的是,“Mount阶段”在CSI Node里的接口,是由NodeStageVolume和NodePublishVolume两个接口共同实现的。我会在下一篇文章中,为你详细介绍这个设计的目的和具体的实现方式。

总结

在本篇文章里,我为你详细讲解了FlexVolume和CSI这两种自定义存储插件的工作原理。

可以看到,相比于FlexVolume,CSI的设计思想,把插件的职责从“两阶段处理”,扩展成了Provision、Attach和Mount三个阶段。其中,Provision等价于“创建磁盘”,Attach等价于“挂载磁盘到虚拟机”,Mount等价于“将该磁盘格式化后,挂载在Volume的宿主机目录上”。

在有了CSI插件之后,Kubernetes本身依然按照我在第28篇文章《PV、PVC、StorageClass,这些到底在说啥?》中所讲述的方式工作,唯一区别在于:

  • 当AttachDetachController需要进行“Attach”操作时(“Attach阶段”),它实际上会执行到pkg/volume/csi目录中,创建一个VolumeAttachment对象,从而触发External Attacher调用CSI Controller服务的ControllerPublishVolume方法。
  • 当VolumeManagerReconciler需要进行“Mount”操作时(“Mount阶段”),它实际上也会执行到pkg/volume/csi目录中,直接向CSI Node服务发起调用NodePublishVolume方法的请求。

以上,就是CSI插件最基本的工作原理了。

在下一篇文章里,我会和你一起实践一个CSI存储插件的完整实现过程。

思考题

假设现在,你的宿主机是阿里云的一台虚拟机,你要实现的容器持久化存储,是基于阿里云提供的云盘。你能准确地描述出,在Provision、Attach和Mount阶段,CSI插件都需要做哪些操作吗?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

31-容器存储实践:CSI插件编写指南

你好,我是张磊。今天我和你分享的主题是:容器存储实践之CSI插件编写指南。

在上一篇文章中,我已经为你详细讲解了CSI插件机制的设计原理。今天我将继续和你一起实践一个CSI插件的编写过程。

为了能够覆盖到CSI插件的所有功能,我这一次选择了DigitalOcean的块存储(Block Storage)服务,来作为实践对象。

DigitalOcean是业界知名的“最简”公有云服务,即:它只提供虚拟机、存储、网络等为数不多的几个基础功能,其他功能一概不管。而这,恰恰就使得DigitalOcean成了我们在公有云上实践Kubernetes的最佳选择。

我们这次编写的CSI插件的功能,就是:让我们运行在DigitalOcean上的Kubernetes集群能够使用它的块存储服务,作为容器的持久化存储。

备注:在DigitalOcean上部署一个Kubernetes集群的过程,也很简单。你只需要先在DigitalOcean上创建几个虚拟机,然后按照我们在第11篇文章《从0到1:搭建一个完整的Kubernetes集群》中从0到1的步骤直接部署即可。

而有了CSI插件之后,持久化存储的用法就非常简单了,你只需要创建一个如下所示的StorageClass对象即可:

kind: StorageClass
apiVersion: storage.k8s.io/v1
metadata:
  name: do-block-storage
  namespace: kube-system
  annotations:
    storageclass.kubernetes.io/is-default-class: "true"
provisioner: com.digitalocean.csi.dobs

有了这个StorageClass,External Provisoner就会为集群中新出现的PVC自动创建出PV,然后调用CSI插件创建出这个PV对应的Volume,这正是CSI体系中Dynamic Provisioning的实现方式。

备注:storageclass.kubernetes.io/is-default-class: "true"的意思,是使用这个StorageClass作为默认的持久化存储提供者。

不难看到,这个StorageClass里唯一引人注意的,是provisioner=com.digitalocean.csi.dobs这个字段。显然,这个字段告诉了Kubernetes,请使用名叫com.digitalocean.csi.dobs的CSI插件来为我处理这个StorageClass相关的所有操作。

那么,Kubernetes又是如何知道一个CSI插件的名字的呢?

这就需要从CSI插件的第一个服务CSI Identity说起了。

其实,一个CSI插件的代码结构非常简单,如下所示:

tree $GOPATH/src/github.com/digitalocean/csi-digitalocean/driver  
$GOPATH/src/github.com/digitalocean/csi-digitalocean/driver
├── controller.go
├── driver.go
├── identity.go
├── mounter.go
└── node.go

其中,CSI Identity服务的实现,就定义在了driver目录下的identity.go文件里。

当然,为了能够让Kubernetes访问到CSI Identity服务,我们需要先在driver.go文件里,定义一个标准的gRPC Server,如下所示:

// Run starts the CSI plugin by communication over the given endpoint
func (d *Driver) Run() error {
 ...
 
 listener, err := net.Listen(u.Scheme, addr)
 ...
 
 d.srv = grpc.NewServer(grpc.UnaryInterceptor(errHandler))
 csi.RegisterIdentityServer(d.srv, d)
 csi.RegisterControllerServer(d.srv, d)
 csi.RegisterNodeServer(d.srv, d)
 
 d.ready = true // we're now ready to go!
 ...
 return d.srv.Serve(listener)
}

可以看到,只要把编写好的gRPC Server注册给CSI,它就可以响应来自External Components的CSI请求了。

CSI Identity服务中,最重要的接口是GetPluginInfo,它返回的就是这个插件的名字和版本号,如下所示:

备注:CSI各个服务的接口我在上一篇文章中已经介绍过,你也可以在这里找到它的protoc文件

func (d *Driver) GetPluginInfo(ctx context.Context, req *csi.GetPluginInfoRequest) (*csi.GetPluginInfoResponse, error) {
 resp := &csi.GetPluginInfoResponse{
  Name:          driverName,
  VendorVersion: version,
 }
 ...
}

其中,driverName的值,正是"com.digitalocean.csi.dobs"。所以说,Kubernetes正是通过GetPluginInfo的返回值,来找到你在StorageClass里声明要使用的CSI插件的。

备注:CSI要求插件的名字遵守“反向DNS”格式

另外一个GetPluginCapabilities接口也很重要。这个接口返回的是这个CSI插件的“能力”。

比如,当你编写的CSI插件不准备实现“Provision阶段”和“Attach阶段”(比如,一个最简单的NFS存储插件就不需要这两个阶段)时,你就可以通过这个接口返回:本插件不提供CSI Controller服务,即:没有csi.PluginCapability_Service_CONTROLLER_SERVICE这个“能力”。这样,Kubernetes就知道这个信息了。

最后,CSI Identity服务还提供了一个Probe接口。Kubernetes会调用它来检查这个CSI插件是否正常工作。

一般情况下,我建议你在编写插件时给它设置一个Ready标志,当插件的gRPC Server停止的时候,把这个Ready标志设置为false。或者,你可以在这里访问一下插件的端口,类似于健康检查的做法。

备注:关于健康检查的问题,你可以再回顾一下第15篇文章《深入解析Pod对象(二):使用进阶》中的相关内容。

然后,我们要开始编写CSI 插件的第二个服务,即CSI Controller服务了。它的代码实现,在controller.go文件里。

在上一篇文章中我已经为你讲解过,这个服务主要实现的就是Volume管理流程中的“Provision阶段”和“Attach阶段”。

“Provision阶段”对应的接口,是CreateVolume和DeleteVolume,它们的调用者是External Provisoner。以CreateVolume为例,它的主要逻辑如下所示:

func (d *Driver) CreateVolume(ctx context.Context, req *csi.CreateVolumeRequest) (*csi.CreateVolumeResponse, error) {
 ...
 
 volumeReq := &godo.VolumeCreateRequest{
  Region:        d.region,
  Name:          volumeName,
  Description:   createdByDO,
  SizeGigaBytes: size / GB,
 }
 
 ...
 
 vol, _, err := d.doClient.Storage.CreateVolume(ctx, volumeReq)
 
 ...
 
 resp := &csi.CreateVolumeResponse{
  Volume: &csi.Volume{
   Id:            vol.ID,
   CapacityBytes: size,
   AccessibleTopology: []*csi.Topology{
    {
     Segments: map[string]string{
      "region": d.region,
     },
    },
   },
  },
 }
 
 return resp, nil
}

可以看到,对于DigitalOcean这样的公有云来说,CreateVolume需要做的操作,就是调用DigitalOcean块存储服务的API,创建出一个存储卷(d.doClient.Storage.CreateVolume)。如果你使用的是其他类型的块存储(比如Cinder、Ceph RBD等),对应的操作也是类似地调用创建存储卷的API。

而“Attach阶段”对应的接口是ControllerPublishVolume和ControllerUnpublishVolume,它们的调用者是External Attacher。以ControllerPublishVolume为例,它的逻辑如下所示:

func (d *Driver) ControllerPublishVolume(ctx context.Context, req *csi.ControllerPublishVolumeRequest) (*csi.ControllerPublishVolumeResponse, error) {
 ...
 
  dropletID, err := strconv.Atoi(req.NodeId)
  
  // check if volume exist before trying to attach it
  _, resp, err := d.doClient.Storage.GetVolume(ctx, req.VolumeId)
 
 ...
 
  // check if droplet exist before trying to attach the volume to the droplet
  _, resp, err = d.doClient.Droplets.Get(ctx, dropletID)
 
 ...
 
  action, resp, err := d.doClient.StorageActions.Attach(ctx, req.VolumeId, dropletID)

 ...
 
 if action != nil {
  ll.Info("waiting until volume is attached")
 if err := d.waitAction(ctx, req.VolumeId, action.ID); err != nil {
  return nil, err
  }
  }
  
  ll.Info("volume is attached")
 return &csi.ControllerPublishVolumeResponse{}, nil
}

可以看到,对于DigitalOcean来说,ControllerPublishVolume在“Attach阶段”需要做的工作,是调用DigitalOcean的API,将我们前面创建的存储卷,挂载到指定的虚拟机上(d.doClient.StorageActions.Attach)。

其中,存储卷由请求中的VolumeId来指定。而虚拟机,也就是将要运行Pod的宿主机,则由请求中的NodeId来指定。这些参数,都是External Attacher在发起请求时需要设置的。

我在上一篇文章中已经为你介绍过,External Attacher的工作原理,是监听(Watch)了一种名叫VolumeAttachment的API对象。这种API对象的主要字段如下所示:

// VolumeAttachmentSpec is the specification of a VolumeAttachment request.
type VolumeAttachmentSpec struct {
 // Attacher indicates the name of the volume driver that MUST handle this
 // request. This is the name returned by GetPluginName().
 Attacher string
 
 // Source represents the volume that should be attached.
 Source VolumeAttachmentSource
 
 // The node that the volume should be attached to.
 NodeName string
}

而这个对象的生命周期,正是由AttachDetachController负责管理的(这里,你可以再回顾一下第28篇文章《PV、PVC、StorageClass,这些到底在说啥?》中的相关内容)。

这个控制循环的职责,是不断检查Pod所对应的PV,在它所绑定的宿主机上的挂载情况,从而决定是否需要对这个PV进行Attach(或者Dettach)操作。

而这个Attach操作,在CSI体系里,就是创建出上面这样一个VolumeAttachment对象。可以看到,Attach操作所需的PV的名字(Source)、宿主机的名字(NodeName)、存储插件的名字(Attacher),都是这个VolumeAttachment对象的一部分。

而当External Attacher监听到这样的一个对象出现之后,就可以立即使用VolumeAttachment里的这些字段,封装成一个gRPC请求调用CSI Controller的ControllerPublishVolume方法。

最后,我们就可以编写CSI Node服务了。

CSI Node服务对应的,是Volume管理流程里的“Mount阶段”。它的代码实现,在node.go文件里。

我在上一篇文章里曾经提到过,kubelet的VolumeManagerReconciler控制循环会直接调用CSI Node服务来完成Volume的“Mount阶段”。

不过,在具体的实现中,这个“Mount阶段”的处理其实被细分成了NodeStageVolume和NodePublishVolume这两个接口。

这里的原因其实也很容易理解:我在第28篇文章《PV、PVC、StorageClass,这些到底在说啥?》中曾经介绍过,对于磁盘以及块设备来说,它们被Attach到宿主机上之后,就成为了宿主机上的一个待用存储设备。而到了“Mount阶段”,我们首先需要格式化这个设备,然后才能把它挂载到Volume对应的宿主机目录上。

在kubelet的VolumeManagerReconciler控制循环中,这两步操作分别叫作MountDevice和SetUp。

其中,MountDevice操作,就是直接调用了CSI Node服务里的NodeStageVolume接口。顾名思义,这个接口的作用,就是格式化Volume在宿主机上对应的存储设备,然后挂载到一个临时目录(Staging目录)上。

对于DigitalOcean来说,它对NodeStageVolume接口的实现如下所示:

func (d *Driver) NodeStageVolume(ctx context.Context, req *csi.NodeStageVolumeRequest) (*csi.NodeStageVolumeResponse, error) {
 ...
 
 vol, resp, err := d.doClient.Storage.GetVolume(ctx, req.VolumeId)
 
 ...
 
 source := getDiskSource(vol.Name)
 target := req.StagingTargetPath
 
 ...
 
 if !formatted {
  ll.Info("formatting the volume for staging")
  if err := d.mounter.Format(source, fsType); err != nil {
   return nil, status.Error(codes.Internal, err.Error())
  }
 } else {
  ll.Info("source device is already formatted")
 }
 
...

 if !mounted {
  if err := d.mounter.Mount(source, target, fsType, options...); err != nil {
   return nil, status.Error(codes.Internal, err.Error())
  }
 } else {
  ll.Info("source device is already mounted to the target path")
 }
 
 ...
 return &csi.NodeStageVolumeResponse{}, nil
}

可以看到,在NodeStageVolume的实现里,我们首先通过DigitalOcean的API获取到了这个Volume对应的设备路径(getDiskSource);然后,我们把这个设备格式化成指定的格式( d.mounter.Format);最后,我们把格式化后的设备挂载到了一个临时的Staging目录(StagingTargetPath)下。

而SetUp操作则会调用CSI Node服务的NodePublishVolume接口。有了上述对设备的预处理工作后,它的实现就非常简单了,如下所示:

func (d *Driver) NodePublishVolume(ctx context.Context, req *csi.NodePublishVolumeRequest) (*csi.NodePublishVolumeResponse, error) {
 ...
 source := req.StagingTargetPath
 target := req.TargetPath
 
 mnt := req.VolumeCapability.GetMount()
 options := mnt.MountFlag
    ...
    
 if !mounted {
  ll.Info("mounting the volume")
  if err := d.mounter.Mount(source, target, fsType, options...); err != nil {
   return nil, status.Error(codes.Internal, err.Error())
  }
 } else {
  ll.Info("volume is already mounted")
 }
 
 return &csi.NodePublishVolumeResponse{}, nil
}

可以看到,在这一步实现中,我们只需要做一步操作,即:将Staging目录,绑定挂载到Volume对应的宿主机目录上。

由于Staging目录,正是Volume对应的设备被格式化后挂载在宿主机上的位置,所以当它和Volume的宿主机目录绑定挂载之后,这个Volume宿主机目录的“持久化”处理也就完成了。

当然,我在前面也曾经提到过,对于文件系统类型的存储服务来说,比如NFS和GlusterFS等,它们并没有一个对应的磁盘“设备”存在于宿主机上,所以kubelet在VolumeManagerReconciler控制循环中,会跳过MountDevice操作而直接执行SetUp操作。所以对于它们来说,也就不需要实现NodeStageVolume接口了。

在编写完了CSI插件之后,我们就可以把这个插件和External Components一起部署起来。

首先,我们需要创建一个DigitalOcean client授权需要使用的Secret对象,如下所示:

apiVersion: v1
kind: Secret
metadata:
  name: digitalocean
  namespace: kube-system
stringData:
  access-token: "a05dd2f26b9b9ac2asdas__REPLACE_ME____123cb5d1ec17513e06da"

接下来,我们通过一句指令就可以将CSI插件部署起来:

$ kubectl apply -f https://raw.githubusercontent.com/digitalocean/csi-digitalocean/master/deploy/kubernetes/releases/csi-digitalocean-v0.2.0.yaml

这个CSI插件的YAML文件的主要内容如下所示(其中,非重要的内容已经被略去):

kind: DaemonSet
apiVersion: apps/v1beta2
metadata:
  name: csi-do-node
  namespace: kube-system
spec:
  selector:
    matchLabels:
      app: csi-do-node
  template:
    metadata:
      labels:
        app: csi-do-node
        role: csi-do
    spec:
      serviceAccount: csi-do-node-sa
      hostNetwork: true
      containers:
        - name: driver-registrar
          image: quay.io/k8scsi/driver-registrar:v0.3.0
          ...
        - name: csi-do-plugin
          image: digitalocean/do-csi-plugin:v0.2.0
          args :
            - "--endpoint=$(CSI_ENDPOINT)"
            - "--token=$(DIGITALOCEAN_ACCESS_TOKEN)"
            - "--url=$(DIGITALOCEAN_API_URL)"
          env:
            - name: CSI_ENDPOINT
              value: unix:///csi/csi.sock
            - name: DIGITALOCEAN_API_URL
              value: https://api.digitalocean.com/
            - name: DIGITALOCEAN_ACCESS_TOKEN
              valueFrom:
                secretKeyRef:
                  name: digitalocean
                  key: access-token
          imagePullPolicy: "Always"
          securityContext:
            privileged: true
            capabilities:
              add: ["SYS_ADMIN"]
            allowPrivilegeEscalation: true
          volumeMounts:
            - name: plugin-dir
              mountPath: /csi
            - name: pods-mount-dir
              mountPath: /var/lib/kubelet
              mountPropagation: "Bidirectional"
            - name: device-dir
              mountPath: /dev
      volumes:
        - name: plugin-dir
          hostPath:
            path: /var/lib/kubelet/plugins/com.digitalocean.csi.dobs
            type: DirectoryOrCreate
        - name: pods-mount-dir
          hostPath:
            path: /var/lib/kubelet
            type: Directory
        - name: device-dir
          hostPath:
            path: /dev
---
kind: StatefulSet
apiVersion: apps/v1beta1
metadata:
  name: csi-do-controller
  namespace: kube-system
spec:
  serviceName: "csi-do"
  replicas: 1
  template:
    metadata:
      labels:
        app: csi-do-controller
        role: csi-do
    spec:
      serviceAccount: csi-do-controller-sa
      containers:
        - name: csi-provisioner
          image: quay.io/k8scsi/csi-provisioner:v0.3.0
          ...
        - name: csi-attacher
          image: quay.io/k8scsi/csi-attacher:v0.3.0
          ...
        - name: csi-do-plugin
          image: digitalocean/do-csi-plugin:v0.2.0
          args :
            - "--endpoint=$(CSI_ENDPOINT)"
            - "--token=$(DIGITALOCEAN_ACCESS_TOKEN)"
            - "--url=$(DIGITALOCEAN_API_URL)"
          env:
            - name: CSI_ENDPOINT
              value: unix:///var/lib/csi/sockets/pluginproxy/csi.sock
            - name: DIGITALOCEAN_API_URL
              value: https://api.digitalocean.com/
            - name: DIGITALOCEAN_ACCESS_TOKEN
              valueFrom:
                secretKeyRef:
                  name: digitalocean
                  key: access-token
          imagePullPolicy: "Always"
          volumeMounts:
            - name: socket-dir
              mountPath: /var/lib/csi/sockets/pluginproxy/
      volumes:
        - name: socket-dir
          emptyDir: {}

可以看到,我们编写的CSI插件只有一个二进制文件,它的镜像是digitalocean/do-csi-plugin:v0.2.0。

而我们部署CSI插件的常用原则是:

第一,通过DaemonSet在每个节点上都启动一个CSI插件,来为kubelet提供CSI Node服务。这是因为,CSI Node服务需要被kubelet直接调用,所以它要和kubelet“一对一”地部署起来。

此外,在上述DaemonSet的定义里面,除了CSI插件,我们还以sidecar的方式运行着driver-registrar这个外部组件。它的作用,是向kubelet注册这个CSI插件。这个注册过程使用的插件信息,则通过访问同一个Pod里的CSI插件容器的Identity服务获取到。

需要注意的是,由于CSI插件运行在一个容器里,那么CSI Node服务在“Mount阶段”执行的挂载操作,实际上是发生在这个容器的Mount Namespace里的。可是,我们真正希望执行挂载操作的对象,都是宿主机/var/lib/kubelet目录下的文件和目录。

所以,在定义DaemonSet Pod的时候,我们需要把宿主机的/var/lib/kubelet以Volume的方式挂载进CSI插件容器的同名目录下,然后设置这个Volume的mountPropagation=Bidirectional,即开启双向挂载传播,从而将容器在这个目录下进行的挂载操作“传播”给宿主机,反之亦然。

第二,通过StatefulSet在任意一个节点上再启动一个CSI插件,为External Components提供CSI Controller服务。所以,作为CSI Controller服务的调用者,External Provisioner和External Attacher这两个外部组件,就需要以sidecar的方式和这次部署的CSI插件定义在同一个Pod里。

你可能会好奇,为什么我们会用StatefulSet而不是Deployment来运行这个CSI插件呢。

这是因为,由于StatefulSet需要确保应用拓扑状态的稳定性,所以它对Pod的更新,是严格保证顺序的,即:只有在前一个Pod停止并删除之后,它才会创建并启动下一个Pod。

而像我们上面这样将StatefulSet的replicas设置为1的话,StatefulSet就会确保Pod被删除重建的时候,永远有且只有一个CSI插件的Pod运行在集群中。这对CSI插件的正确性来说,至关重要。

而在今天这篇文章一开始,我们就已经定义了这个CSI插件对应的StorageClass(即:do-block-storage),所以你接下来只需要定义一个声明使用这个StorageClass的PVC即可,如下所示:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: csi-pvc
spec:
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 5Gi
  storageClassName: do-block-storage

当你把上述PVC提交给Kubernetes之后,你就可以在Pod里声明使用这个csi-pvc来作为持久化存储了。这一部分使用PV和PVC的内容,我就不再赘述了。

总结

在今天这篇文章中,我以一个DigitalOcean的CSI插件为例,和你分享了编写CSI插件的具体流程。

基于这些讲述,你现在应该已经对Kubernetes持久化存储体系有了一个更加全面和深入的认识。

举个例子,对于一个部署了CSI存储插件的Kubernetes集群来说:

当用户创建了一个PVC之后,你前面部署的StatefulSet里的External Provisioner容器,就会监听到这个PVC的诞生,然后调用同一个Pod里的CSI插件的CSI Controller服务的CreateVolume方法,为你创建出对应的PV。

这时候,运行在Kubernetes Master节点上的Volume Controller,就会通过PersistentVolumeController控制循环,发现这对新创建出来的PV和PVC,并且看到它们声明的是同一个StorageClass。所以,它会把这一对PV和PVC绑定起来,使PVC进入Bound状态。

然后,用户创建了一个声明使用上述PVC的Pod,并且这个Pod被调度器调度到了宿主机A上。这时候,Volume Controller的AttachDetachController控制循环就会发现,上述PVC对应的Volume,需要被Attach到宿主机A上。所以,AttachDetachController会创建一个VolumeAttachment对象,这个对象携带了宿主机A和待处理的Volume的名字。

这样,StatefulSet里的External Attacher容器,就会监听到这个VolumeAttachment对象的诞生。于是,它就会使用这个对象里的宿主机和Volume名字,调用同一个Pod里的CSI插件的CSI Controller服务的ControllerPublishVolume方法,完成“Attach阶段”。

上述过程完成后,运行在宿主机A上的kubelet,就会通过VolumeManagerReconciler控制循环,发现当前宿主机上有一个Volume对应的存储设备(比如磁盘)已经被Attach到了某个设备目录下。于是kubelet就会调用同一台宿主机上的CSI插件的CSI Node服务的NodeStageVolume和NodePublishVolume方法,完成这个Volume的“Mount阶段”。

至此,一个完整的持久化Volume的创建和挂载流程就结束了。

思考题

请你根据编写FlexVolume和CSI插件的流程,分析一下什么时候该使用FlexVolume,什么时候应该使用CSI?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

32-浅谈容器网络

你好,我是张磊。今天我和你分享的主题是:浅谈容器网络。

在前面讲解容器基础时,我曾经提到过一个Linux容器能看见的“网络栈”,实际上是被隔离在它自己的Network Namespace当中的。

而所谓“网络栈”,就包括了:网卡(Network Interface)、回环设备(Loopback Device)、路由表(Routing Table)和iptables规则。对于一个进程来说,这些要素,其实就构成了它发起和响应网络请求的基本环境。

需要指出的是,作为一个容器,它可以声明直接使用宿主机的网络栈(–net=host),即:不开启Network Namespace,比如:

$ docker run –d –net=host --name nginx-host nginx

在这种情况下,这个容器启动后,直接监听的就是宿主机的80端口。

像这样直接使用宿主机网络栈的方式,虽然可以为容器提供良好的网络性能,但也会不可避免地引入共享网络资源的问题,比如端口冲突。所以,在大多数情况下,我们都希望容器进程能使用自己Network Namespace里的网络栈,即:拥有属于自己的IP地址和端口。

这时候,一个显而易见的问题就是:这个被隔离的容器进程,该如何跟其他Network Namespace里的容器进程进行交互呢?

为了理解这个问题,你其实可以把每一个容器看做一台主机,它们都有一套独立的“网络栈”。

如果你想要实现两台主机之间的通信,最直接的办法,就是把它们用一根网线连接起来;而如果你想要实现多台主机之间的通信,那就需要用网线,把它们连接在一台交换机上。

在Linux中,能够起到虚拟交换机作用的网络设备,是网桥(Bridge)。它是一个工作在数据链路层(Data Link)的设备,主要功能是根据MAC地址学习来将数据包转发到网桥的不同端口(Port)上。

当然,至于为什么这些主机之间需要MAC地址才能进行通信,这就是网络分层模型的基础知识了。不熟悉这块内容的读者,可以通过这篇文章来学习一下。

而为了实现上述目的,Docker项目会默认在宿主机上创建一个名叫docker0的网桥,凡是连接在docker0网桥上的容器,就可以通过它来进行通信。

可是,我们又该如何把这些容器“连接”到docker0网桥上呢?

这时候,我们就需要使用一种名叫Veth Pair的虚拟设备了。

Veth Pair设备的特点是:它被创建出来后,总是以两张虚拟网卡(Veth Peer)的形式成对出现的。并且,从其中一个“网卡”发出的数据包,可以直接出现在与它对应的另一张“网卡”上,哪怕这两个“网卡”在不同的Network Namespace里。

这就使得Veth Pair常常被用作连接不同Network Namespace 的“网线”。

比如,现在我们启动了一个叫作nginx-1的容器:

$ docker run –d --name nginx-1 nginx

然后进入到这个容器中查看一下它的网络设备:

# 在宿主机上
$ docker exec -it nginx-1 /bin/bash
# 在容器里
root@2b3c181aecf1:/# ifconfig
eth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 172.17.0.2  netmask 255.255.0.0  broadcast 0.0.0.0
        inet6 fe80::42:acff:fe11:2  prefixlen 64  scopeid 0x20<link>
        ether 02:42:ac:11:00:02  txqueuelen 0  (Ethernet)
        RX packets 364  bytes 8137175 (7.7 MiB)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 281  bytes 21161 (20.6 KiB)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0
        
lo: flags=73<UP,LOOPBACK,RUNNING>  mtu 65536
        inet 127.0.0.1  netmask 255.0.0.0
        inet6 ::1  prefixlen 128  scopeid 0x10<host>
        loop  txqueuelen 1000  (Local Loopback)
        RX packets 0  bytes 0 (0.0 B)
        RX errors 0  dropped 0  overruns 0  frame 0
        TX packets 0  bytes 0 (0.0 B)
        TX errors 0  dropped 0 overruns 0  carrier 0  collisions 0
        
$ route
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
default         172.17.0.1      0.0.0.0         UG    0      0        0 eth0
172.17.0.0      0.0.0.0         255.255.0.0     U     0      0        0 eth0

可以看到,这个容器里有一张叫作eth0的网卡,它正是一个Veth Pair设备在容器里的这一端。

通过route命令查看nginx-1容器的路由表,我们可以看到,这个eth0网卡是这个容器里的默认路由设备;所有对172.17.0.0/16网段的请求,也会被交给eth0来处理(第二条172.17.0.0路由规则)。

而这个Veth Pair设备的另一端,则在宿主机上。你可以通过查看宿主机的网络设备看到它,如下所示:

# 在宿主机上
$ ifconfig
...
docker0   Link encap:Ethernet  HWaddr 02:42:d8:e4:df:c1  
          inet addr:172.17.0.1  Bcast:0.0.0.0  Mask:255.255.0.0
          inet6 addr: fe80::42:d8ff:fee4:dfc1/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:309 errors:0 dropped:0 overruns:0 frame:0
          TX packets:372 errors:0 dropped:0 overruns:0 carrier:0
 collisions:0 txqueuelen:0
          RX bytes:18944 (18.9 KB)  TX bytes:8137789 (8.1 MB)
veth9c02e56 Link encap:Ethernet  HWaddr 52:81:0b:24:3d:da  
          inet6 addr: fe80::5081:bff:fe24:3dda/64 Scope:Link
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:288 errors:0 dropped:0 overruns:0 frame:0
          TX packets:371 errors:0 dropped:0 overruns:0 carrier:0
 collisions:0 txqueuelen:0
          RX bytes:21608 (21.6 KB)  TX bytes:8137719 (8.1 MB)
          
$ brctl show
bridge name bridge id  STP enabled interfaces
docker0  8000.0242d8e4dfc1 no  veth9c02e56

通过ifconfig命令的输出,你可以看到,nginx-1容器对应的Veth Pair设备,在宿主机上是一张虚拟网卡。它的名字叫作veth9c02e56。并且,通过brctl show的输出,你可以看到这张网卡被“插”在了docker0上。

这时候,如果我们再在这台宿主机上启动另一个Docker容器,比如nginx-2:

$ docker run –d --name nginx-2 nginx
$ brctl show
bridge name bridge id  STP enabled interfaces
docker0  8000.0242d8e4dfc1 no  veth9c02e56
       vethb4963f3

你就会发现一个新的、名叫vethb4963f3的虚拟网卡,也被“插”在了docker0网桥上。

这时候,如果你在nginx-1容器里ping一下nginx-2容器的IP地址(172.17.0.3),就会发现同一宿主机上的两个容器默认就是相互连通的。

这其中的原理其实非常简单,我来解释一下。

当你在nginx-1容器里访问nginx-2容器的IP地址(比如ping 172.17.0.3)的时候,这个目的IP地址会匹配到nginx-1容器里的第二条路由规则。可以看到,这条路由规则的网关(Gateway)是0.0.0.0,这就意味着这是一条直连规则,即:凡是匹配到这条规则的IP包,应该经过本机的eth0网卡,通过二层网络直接发往目的主机。

而要通过二层网络到达nginx-2容器,就需要有172.17.0.3这个IP地址对应的MAC地址。所以nginx-1容器的网络协议栈,就需要通过eth0网卡发送一个ARP广播,来通过IP地址查找对应的MAC地址。

备注:ARP(Address Resolution Protocol),是通过三层的IP地址找到对应的二层MAC地址的协议。

我们前面提到过,这个eth0网卡,是一个Veth Pair,它的一端在这个nginx-1容器的Network Namespace里,而另一端则位于宿主机上(Host Namespace),并且被“插”在了宿主机的docker0网桥上。

一旦一张虚拟网卡被“插”在网桥上,它就会变成该网桥的“从设备”。从设备会被“剥夺”调用网络协议栈处理数据包的资格,从而“降级”成为网桥上的一个端口。而这个端口唯一的作用,就是接收流入的数据包,然后把这些数据包的“生杀大权”(比如转发或者丢弃),全部交给对应的网桥。

所以,在收到这些ARP请求之后,docker0网桥就会扮演二层交换机的角色,把ARP广播转发到其他被“插”在docker0上的虚拟网卡上。这样,同样连接在docker0上的nginx-2容器的网络协议栈就会收到这个ARP请求,从而将172.17.0.3所对应的MAC地址回复给nginx-1容器。

有了这个目的MAC地址,nginx-1容器的eth0网卡就可以将数据包发出去。

而根据Veth Pair设备的原理,这个数据包会立刻出现在宿主机上的veth9c02e56虚拟网卡上。不过,此时这个veth9c02e56网卡的网络协议栈的资格已经被“剥夺”,所以这个数据包就直接流入到了docker0网桥里。

docker0处理转发的过程,则继续扮演二层交换机的角色。此时,docker0网桥根据数据包的目的MAC地址(也就是nginx-2容器的MAC地址),在它的CAM表(即交换机通过MAC地址学习维护的端口和MAC地址的对应表)里查到对应的端口(Port)为:vethb4963f3,然后把数据包发往这个端口。

而这个端口,正是nginx-2容器“插”在docker0网桥上的另一块虚拟网卡,当然,它也是一个Veth Pair设备。这样,数据包就进入到了nginx-2容器的Network Namespace里。

所以,nginx-2容器看到的情况是,它自己的eth0网卡上出现了流入的数据包。这样,nginx-2的网络协议栈就会对请求进行处理,最后将响应(Pong)返回到nginx-1。

以上,就是同一个宿主机上的不同容器通过docker0网桥进行通信的流程了。我把这个流程总结成了一幅示意图,如下所示:


需要注意的是,在实际的数据传递时,上述数据的传递过程在网络协议栈的不同层次,都有Linux内核Netfilter参与其中。所以,如果感兴趣的话,你可以通过打开iptables的TRACE功能查看到数据包的传输过程,具体方法如下所示:

# 在宿主机上执行
$ iptables -t raw -A OUTPUT -p icmp -j TRACE
$ iptables -t raw -A PREROUTING -p icmp -j TRACE

通过上述设置,你就可以在/var/log/syslog里看到数据包传输的日志了。这一部分内容,你可以在课后结合iptables的相关知识进行实践,从而验证我和你分享的数据包传递流程。

熟悉了docker0网桥的工作方式,你就可以理解,在默认情况下,被限制在Network Namespace里的容器进程,实际上是通过Veth Pair设备+宿主机网桥的方式,实现了跟同其他容器的数据交换。

与之类似地,当你在一台宿主机上,访问该宿主机上的容器的IP地址时,这个请求的数据包,也是先根据路由规则到达docker0网桥,然后被转发到对应的Veth Pair设备,最后出现在容器里。这个过程的示意图,如下所示:


同样地,当一个容器试图连接到另外一个宿主机时,比如:ping 10.168.0.3,它发出的请求数据包,首先经过docker0网桥出现在宿主机上。然后根据宿主机的路由表里的直连路由规则(10.168.0.0/24 via eth0)),对10.168.0.3的访问请求就会交给宿主机的eth0处理。

所以接下来,这个数据包就会经宿主机的eth0网卡转发到宿主机网络上,最终到达10.168.0.3对应的宿主机上。当然,这个过程的实现要求这两台宿主机本身是连通的。这个过程的示意图,如下所示:


所以说,当你遇到容器连不通“外网”的时候,你都应该先试试docker0网桥能不能ping通,然后查看一下跟docker0和Veth Pair设备相关的iptables规则是不是有异常,往往就能够找到问题的答案了。

不过,在最后一个“Docker容器连接其他宿主机”的例子里,你可能已经联想到了这样一个问题:如果在另外一台宿主机(比如:10.168.0.3)上,也有一个Docker容器。那么,我们的nginx-1容器又该如何访问它呢?

这个问题,其实就是容器的“跨主通信”问题。

在Docker的默认配置下,一台宿主机上的docker0网桥,和其他宿主机上的docker0网桥,没有任何关联,它们互相之间也没办法连通。所以,连接在这些网桥上的容器,自然也没办法进行通信了。

不过,万变不离其宗。

如果我们通过软件的方式,创建一个整个集群“公用”的网桥,然后把集群里的所有容器都连接到这个网桥上,不就可以相互通信了吗?

说得没错。

这样一来,我们整个集群里的容器网络就会类似于下图所示的样子:


可以看到,构建这种容器网络的核心在于:我们需要在已有的宿主机网络上,再通过软件构建一个覆盖在已有宿主机网络之上的、可以把所有容器连通在一起的虚拟网络。所以,这种技术就被称为:Overlay Network(覆盖网络)。

而这个Overlay Network本身,可以由每台宿主机上的一个“特殊网桥”共同组成。比如,当Node 1上的Container 1要访问Node 2上的Container 3的时候,Node 1上的“特殊网桥”在收到数据包之后,能够通过某种方式,把数据包发送到正确的宿主机,比如Node 2上。而Node 2上的“特殊网桥”在收到数据包后,也能够通过某种方式,把数据包转发给正确的容器,比如Container 3。

甚至,每台宿主机上,都不需要有一个这种特殊的网桥,而仅仅通过某种方式配置宿主机的路由表,就能够把数据包转发到正确的宿主机上。这些内容,我在后面的文章中会为你一一讲述。

总结

在今天这篇文章中,我主要为你介绍了在本地环境下,单机容器网络的实现原理和docker0网桥的作用。

这里的关键在于,容器要想跟外界进行通信,它发出的IP包就必须从它的Network Namespace里出来,来到宿主机上。

而解决这个问题的方法就是:为容器创建一个一端在容器里充当默认网卡、另一端在宿主机上的Veth Pair设备。

上述单机容器网络的知识,是后面我们讲解多机容器网络的重要基础,请务必认真消化理解。

思考题

尽管容器的Host Network模式有一些缺点,但是它性能好、配置简单,并且易于调试,所以很多团队会直接使用Host Network。那么,如果要在生产环境中使用容器的Host Network模式,你觉得需要做哪些额外的准备工作呢?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

33-深入解析容器跨主机网络

你好,我是张磊。今天我和你分享的主题是:深入解析容器跨主机网络。

在上一篇文章中,我为你详细讲解了在单机环境下,Linux容器网络的实现原理(网桥模式)。并且提到了,在Docker的默认配置下,不同宿主机上的容器通过IP地址进行互相访问是根本做不到的。

而正是为了解决这个容器“跨主通信”的问题,社区里才出现了那么多的容器网络方案。而且,相信你一直以来都有这样的疑问:这些网络方案的工作原理到底是什么?

要理解容器“跨主通信”的原理,就一定要先从Flannel这个项目说起。

Flannel项目是CoreOS公司主推的容器网络方案。事实上,Flannel项目本身只是一个框架,真正为我们提供容器网络功能的,是Flannel的后端实现。目前,Flannel支持三种后端实现,分别是:

  1. VXLAN;

  2. host-gw;

  3. UDP。

这三种不同的后端实现,正代表了三种容器跨主网络的主流实现方法。其中,host-gw模式,我会在下一篇文章中再做详细介绍。

而UDP模式,是Flannel项目最早支持的一种方式,却也是性能最差的一种方式。所以,这个模式目前已经被弃用。不过,Flannel之所以最先选择UDP模式,就是因为这种模式是最直接、也是最容易理解的容器跨主网络实现。

所以,在今天这篇文章中,我会先从UDP模式开始,来为你讲解容器“跨主网络”的实现原理。

在这个例子中,我有两台宿主机。

  • 宿主机Node 1上有一个容器container-1,它的IP地址是100.96.1.2,对应的docker0网桥的地址是:100.96.1.1/24。
  • 宿主机Node 2上有一个容器container-2,它的IP地址是100.96.2.3,对应的docker0网桥的地址是:100.96.2.1/24。

我们现在的任务,就是让container-1访问container-2。

这种情况下,container-1容器里的进程发起的IP包,其源地址就是100.96.1.2,目的地址就是100.96.2.3。由于目的地址100.96.2.3并不在Node 1的docker0网桥的网段里,所以这个IP包会被交给默认路由规则,通过容器的网关进入docker0网桥(如果是同一台宿主机上的容器间通信,走的是直连规则),从而出现在宿主机上。

这时候,这个IP包的下一个目的地,就取决于宿主机上的路由规则了。此时,Flannel已经在宿主机上创建出了一系列的路由规则,以Node 1为例,如下所示:

# 在Node 1上
$ ip route
default via 10.168.0.1 dev eth0
100.96.0.0/16 dev flannel0  proto kernel  scope link  src 100.96.1.0
100.96.1.0/24 dev docker0  proto kernel  scope link  src 100.96.1.1
10.168.0.0/24 dev eth0  proto kernel  scope link  src 10.168.0.2

可以看到,由于我们的IP包的目的地址是100.96.2.3,它匹配不到本机docker0网桥对应的100.96.1.0/24网段,只能匹配到第二条、也就是100.96.0.0/16对应的这条路由规则,从而进入到一个叫作flannel0的设备中。

而这个flannel0设备的类型就比较有意思了:它是一个TUN设备(Tunnel设备)。

在Linux中,TUN设备是一种工作在三层(Network Layer)的虚拟网络设备。TUN设备的功能非常简单,即:在操作系统内核和用户应用程序之间传递IP包。

以flannel0设备为例:像上面提到的情况,当操作系统将一个IP包发送给flannel0设备之后,flannel0就会把这个IP包,交给创建这个设备的应用程序,也就是Flannel进程。这是一个从内核态(Linux操作系统)向用户态(Flannel进程)的流动方向。

反之,如果Flannel进程向flannel0设备发送了一个IP包,那么这个IP包就会出现在宿主机网络栈中,然后根据宿主机的路由表进行下一步处理。这是一个从用户态向内核态的流动方向。

所以,当IP包从容器经过docker0出现在宿主机,然后又根据路由表进入flannel0设备后,宿主机上的flanneld进程(Flannel项目在每个宿主机上的主进程),就会收到这个IP包。然后,flanneld看到了这个IP包的目的地址,是100.96.2.3,就把它发送给了Node 2宿主机。

等一下,flanneld又是如何知道这个IP地址对应的容器,是运行在Node 2上的呢?

这里,就用到了Flannel项目里一个非常重要的概念:子网(Subnet)。

事实上,在由Flannel管理的容器网络里,一台宿主机上的所有容器,都属于该宿主机被分配的一个“子网”。在我们的例子中,Node 1的子网是100.96.1.0/24,container-1的IP地址是100.96.1.2。Node 2的子网是100.96.2.0/24,container-2的IP地址是100.96.2.3。

而这些子网与宿主机的对应关系,正是保存在Etcd当中,如下所示:

$ etcdctl ls /coreos.com/network/subnets
/coreos.com/network/subnets/100.96.1.0-24
/coreos.com/network/subnets/100.96.2.0-24
/coreos.com/network/subnets/100.96.3.0-24

所以,flanneld进程在处理由flannel0传入的IP包时,就可以根据目的IP的地址(比如100.96.2.3),匹配到对应的子网(比如100.96.2.0/24),从Etcd中找到这个子网对应的宿主机的IP地址是10.168.0.3,如下所示:

$ etcdctl get /coreos.com/network/subnets/100.96.2.0-24
{"PublicIP":"10.168.0.3"}

而对于flanneld来说,只要Node 1和Node 2是互通的,那么flanneld作为Node 1上的一个普通进程,就一定可以通过上述IP地址(10.168.0.3)访问到Node 2,这没有任何问题。

所以说,flanneld在收到container-1发给container-2的IP包之后,就会把这个IP包直接封装在一个UDP包里,然后发送给Node 2。不难理解,这个UDP包的源地址,就是flanneld所在的Node 1的地址,而目的地址,则是container-2所在的宿主机Node 2的地址。

当然,这个请求得以完成的原因是,每台宿主机上的flanneld,都监听着一个8285端口,所以flanneld只要把UDP包发往Node 2的8285端口即可。

通过这样一个普通的、宿主机之间的UDP通信,一个UDP包就从Node 1到达了Node 2。而Node 2上监听8285端口的进程也是flanneld,所以这时候,flanneld就可以从这个UDP包里解析出封装在里面的、container-1发来的原IP包。

而接下来flanneld的工作就非常简单了:flanneld会直接把这个IP包发送给它所管理的TUN设备,即flannel0设备。

根据我前面讲解的TUN设备的原理,这正是一个从用户态向内核态的流动方向(Flannel进程向TUN设备发送数据包),所以Linux内核网络栈就会负责处理这个IP包,具体的处理方法,就是通过本机的路由表来寻找这个IP包的下一步流向。

而Node 2上的路由表,跟Node 1非常类似,如下所示:

# 在Node 2上
$ ip route
default via 10.168.0.1 dev eth0
100.96.0.0/16 dev flannel0  proto kernel  scope link  src 100.96.2.0
100.96.2.0/24 dev docker0  proto kernel  scope link  src 100.96.2.1
10.168.0.0/24 dev eth0  proto kernel  scope link  src 10.168.0.3

由于这个IP包的目的地址是100.96.2.3,它跟第三条、也就是100.96.2.0/24网段对应的路由规则匹配更加精确。所以,Linux内核就会按照这条路由规则,把这个IP包转发给docker0网桥。

接下来的流程,就如同我在上一篇文章《浅谈容器网络》中和你分享的那样,docker0网桥会扮演二层交换机的角色,将数据包发送给正确的端口,进而通过Veth Pair设备进入到container-2的Network Namespace里。

而container-2返回给container-1的数据包,则会经过与上述过程完全相反的路径回到container-1中。

需要注意的是,上述流程要正确工作还有一个重要的前提,那就是docker0网桥的地址范围必须是Flannel为宿主机分配的子网。这个很容易实现,以Node 1为例,你只需要给它上面的Docker Daemon启动时配置如下所示的bip参数即可:

$ FLANNEL_SUBNET=100.96.1.1/24
$ dockerd --bip=$FLANNEL_SUBNET ...

以上,就是基于Flannel UDP模式的跨主通信的基本原理了。我把它总结成了一幅原理图,如下所示。

可以看到,Flannel UDP模式提供的其实是一个三层的Overlay网络,即:它首先对发出端的IP包进行UDP封装,然后在接收端进行解封装拿到原始的IP包,进而把这个IP包转发给目标容器。这就好比,Flannel在不同宿主机上的两个容器之间打通了一条“隧道”,使得这两个容器可以直接使用IP地址进行通信,而无需关心容器和宿主机的分布情况。

我前面曾经提到,上述UDP模式有严重的性能问题,所以已经被废弃了。通过我上面的讲述,你有没有发现性能问题出现在了哪里呢?

实际上,相比于两台宿主机之间的直接通信,基于Flannel UDP模式的容器通信多了一个额外的步骤,即flanneld的处理过程。而这个过程,由于使用到了flannel0这个TUN设备,仅在发出IP包的过程中,就需要经过三次用户态与内核态之间的数据拷贝,如下所示:

我们可以看到:

第一次,用户态的容器进程发出的IP包经过docker0网桥进入内核态;

第二次,IP包根据路由表进入TUN(flannel0)设备,从而回到用户态的flanneld进程;

第三次,flanneld进行UDP封包之后重新进入内核态,将UDP包通过宿主机的eth0发出去。

此外,我们还可以看到,Flannel进行UDP封装(Encapsulation)和解封装(Decapsulation)的过程,也都是在用户态完成的。在Linux操作系统中,上述这些上下文切换和用户态操作的代价其实是比较高的,这也正是造成Flannel UDP模式性能不好的主要原因。

所以说,我们在进行系统级编程的时候,有一个非常重要的优化原则,就是要减少用户态到内核态的切换次数,并且把核心的处理逻辑都放在内核态进行。这也是为什么,Flannel后来支持的VXLAN模式,逐渐成为了主流的容器网络方案的原因。

VXLAN,即Virtual Extensible LAN(虚拟可扩展局域网),是Linux内核本身就支持的一种网络虚似化技术。所以说,VXLAN可以完全在内核态实现上述封装和解封装的工作,从而通过与前面相似的“隧道”机制,构建出覆盖网络(Overlay Network)。

VXLAN的覆盖网络的设计思想是:在现有的三层网络之上,“覆盖”一层虚拟的、由内核VXLAN模块负责维护的二层网络,使得连接在这个VXLAN二层网络上的“主机”(虚拟机或者容器都可以)之间,可以像在同一个局域网(LAN)里那样自由通信。当然,实际上,这些“主机”可能分布在不同的宿主机上,甚至是分布在不同的物理机房里。

而为了能够在二层网络上打通“隧道”,VXLAN会在宿主机上设置一个特殊的网络设备作为“隧道”的两端。这个设备就叫作VTEP,即:VXLAN Tunnel End Point(虚拟隧道端点)。

而VTEP设备的作用,其实跟前面的flanneld进程非常相似。只不过,它进行封装和解封装的对象,是二层数据帧(Ethernet frame);而且这个工作的执行流程,全部是在内核里完成的(因为VXLAN本身就是Linux内核中的一个模块)。

上述基于VTEP设备进行“隧道”通信的流程,我也为你总结成了一幅图,如下所示:

可以看到,图中每台宿主机上名叫flannel.1的设备,就是VXLAN所需的VTEP设备,它既有IP地址,也有MAC地址。

现在,我们的container-1的IP地址是10.1.15.2,要访问的container-2的IP地址是10.1.16.3。

那么,与前面UDP模式的流程类似,当container-1发出请求之后,这个目的地址是10.1.16.3的IP包,会先出现在docker0网桥,然后被路由到本机flannel.1设备进行处理。也就是说,来到了“隧道”的入口。为了方便叙述,我接下来会把这个IP包称为“原始IP包”。

为了能够将“原始IP包”封装并且发送到正确的宿主机,VXLAN就需要找到这条“隧道”的出口,即:目的宿主机的VTEP设备。

而这个设备的信息,正是每台宿主机上的flanneld进程负责维护的。

比如,当Node 2启动并加入Flannel网络之后,在Node 1(以及所有其他节点)上,flanneld就会添加一条如下所示的路由规则:

$ route -n
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
...
10.1.16.0       10.1.16.0       255.255.255.0   UG    0      0        0 flannel.1

这条规则的意思是:凡是发往10.1.16.0/24网段的IP包,都需要经过flannel.1设备发出,并且,它最后被发往的网关地址是:10.1.16.0。

从图3的Flannel VXLAN模式的流程图中我们可以看到,10.1.16.0正是Node 2上的VTEP设备(也就是flannel.1设备)的IP地址。

为了方便叙述,接下来我会把Node 1和Node 2上的flannel.1设备分别称为“源VTEP设备”和“目的VTEP设备”。

而这些VTEP设备之间,就需要想办法组成一个虚拟的二层网络,即:通过二层数据帧进行通信。

所以在我们的例子中,“源VTEP设备”收到“原始IP包”后,就要想办法把“原始IP包”加上一个目的MAC地址,封装成一个二层数据帧,然后发送给“目的VTEP设备”(当然,这么做还是因为这个IP包的目的地址不是本机)。

这里需要解决的问题就是:“目的VTEP设备”的MAC地址是什么?

此时,根据前面的路由记录,我们已经知道了“目的VTEP设备”的IP地址。而要根据三层IP地址查询对应的二层MAC地址,这正是ARP(Address Resolution Protocol )表的功能。

而这里要用到的ARP记录,也是flanneld进程在Node 2节点启动时,自动添加在Node 1上的。我们可以通过ip命令看到它,如下所示:

# 在Node 1上
$ ip neigh show dev flannel.1
10.1.16.0 lladdr 5e:f8:4f:00:e3:37 PERMANENT

这条记录的意思非常明确,即:IP地址10.1.16.0,对应的MAC地址是5e:f8:4f:00:e3:37。

可以看到,最新版本的Flannel并不依赖L3 MISS事件和ARP学习,而会在每台节点启动时把它的VTEP设备对应的ARP记录,直接下放到其他每台宿主机上。

有了这个“目的VTEP设备”的MAC地址,Linux内核就可以开始二层封包工作了。这个二层帧的格式,如下所示:

可以看到,Linux内核会把“目的VTEP设备”的MAC地址,填写在图中的Inner Ethernet Header字段,得到一个二层数据帧。

需要注意的是,上述封包过程只是加一个二层头,不会改变“原始IP包”的内容。所以图中的Inner IP Header字段,依然是container-2的IP地址,即10.1.16.3。

但是,上面提到的这些VTEP设备的MAC地址,对于宿主机网络来说并没有什么实际意义。所以上面封装出来的这个数据帧,并不能在我们的宿主机二层网络里传输。为了方便叙述,我们把它称为“内部数据帧”(Inner Ethernet Frame)。

所以接下来,Linux内核还需要再把“内部数据帧”进一步封装成为宿主机网络里的一个普通的数据帧,好让它“载着”“内部数据帧”,通过宿主机的eth0网卡进行传输。

我们把这次要封装出来的、宿主机对应的数据帧称为“外部数据帧”(Outer Ethernet Frame)。

为了实现这个“搭便车”的机制,Linux内核会在“内部数据帧”前面,加上一个特殊的VXLAN头,用来表示这个“乘客”实际上是一个VXLAN要使用的数据帧。

而这个VXLAN头里有一个重要的标志叫作VNI,它是VTEP设备识别某个数据帧是不是应该归自己处理的重要标识。而在Flannel中,VNI的默认值是1,这也是为何,宿主机上的VTEP设备都叫作flannel.1的原因,这里的“1”,其实就是VNI的值。

然后,Linux内核会把这个数据帧封装进一个UDP包里发出去。

所以,跟UDP模式类似,在宿主机看来,它会以为自己的flannel.1设备只是在向另外一台宿主机的flannel.1设备,发起了一次普通的UDP链接。它哪里会知道,这个UDP包里面,其实是一个完整的二层数据帧。这是不是跟特洛伊木马的故事非常像呢?

不过,不要忘了,一个flannel.1设备只知道另一端的flannel.1设备的MAC地址,却不知道对应的宿主机地址是什么。

也就是说,这个UDP包该发给哪台宿主机呢?

在这种场景下,flannel.1设备实际上要扮演一个“网桥”的角色,在二层网络进行UDP包的转发。而在Linux内核里面,“网桥”设备进行转发的依据,来自于一个叫作FDB(Forwarding Database)的转发数据库。

不难想到,这个flannel.1“网桥”对应的FDB信息,也是flanneld进程负责维护的。它的内容可以通过bridge fdb命令查看到,如下所示:

# 在Node 1上,使用“目的VTEP设备”的MAC地址进行查询
$ bridge fdb show flannel.1 | grep 5e:f8:4f:00:e3:37
5e:f8:4f:00:e3:37 dev flannel.1 dst 10.168.0.3 self permanent

可以看到,在上面这条FDB记录里,指定了这样一条规则,即:

发往我们前面提到的“目的VTEP设备”(MAC地址是5e:f8:4f:00:e3:37)的二层数据帧,应该通过flannel.1设备,发往IP地址为10.168.0.3的主机。显然,这台主机正是Node 2,UDP包要发往的目的地就找到了。

所以接下来的流程,就是一个正常的、宿主机网络上的封包工作。

我们知道,UDP包是一个四层数据包,所以Linux内核会在它前面加上一个IP头,即原理图中的Outer IP Header,组成一个IP包。并且,在这个IP头里,会填上前面通过FDB查询出来的目的主机的IP地址,即Node 2的IP地址10.168.0.3。

然后,Linux内核再在这个IP包前面加上二层数据帧头,即原理图中的Outer Ethernet Header,并把Node 2的MAC地址填进去。这个MAC地址本身,是Node 1的ARP表要学习的内容,无需Flannel维护。这时候,我们封装出来的“外部数据帧”的格式,如下所示:

这样,封包工作就宣告完成了。

接下来,Node 1上的flannel.1设备就可以把这个数据帧从Node 1的eth0网卡发出去。显然,这个帧会经过宿主机网络来到Node 2的eth0网卡。

这时候,Node 2的内核网络栈会发现这个数据帧里有VXLAN Header,并且VNI=1。所以Linux内核会对它进行拆包,拿到里面的内部数据帧,然后根据VNI的值,把它交给Node 2上的flannel.1设备。

而flannel.1设备则会进一步拆包,取出“原始IP包”。接下来就回到了我在上一篇文章中分享的单机容器网络的处理流程。最终,IP包就进入到了container-2容器的Network Namespace里。

以上,就是Flannel VXLAN模式的具体工作原理了。

总结

在本篇文章中,我为你详细讲解了Flannel UDP和VXLAN模式的工作原理。这两种模式其实都可以称作“隧道”机制,也是很多其他容器网络插件的基础。比如Weave的两种模式,以及Docker的Overlay模式。

此外,从上面的讲解中我们可以看到,VXLAN模式组建的覆盖网络,其实就是一个由不同宿主机上的VTEP设备,也就是flannel.1设备组成的虚拟二层网络。对于VTEP设备来说,它发出的“内部数据帧”就仿佛是一直在这个虚拟的二层网络上流动。这,也正是覆盖网络的含义。

备注:如果你想要在我们前面部署的集群中实践Flannel的话,可以在Master节点上执行如下命令来替换网络插件。
第一步,执行$ rm -rf /etc/cni/net.d/*
第二步,执行$ kubectl delete -f "https://cloud.weave.works/k8s/net?k8s-version=1.11"
第三步,在/etc/kubernetes/manifests/kube-controller-manager.yaml里,为容器启动命令添加如下两个参数:
--allocate-node-cidrs=true
--cluster-cidr=10.244.0.0/16
第四步, 重启所有kubelet;
第五步, 执行$ kubectl create -f https://raw.githubusercontent.com/coreos/flannel/bc79dd1505b0c8681ece4de4c0d86c5cd2643275/Documentation/kube-flannel.yml

思考题

可以看到,Flannel通过上述的“隧道”机制,实现了容器之间三层网络(IP地址)的连通性。但是,根据这个机制的工作原理,你认为Flannel能负责保证二层网络(MAC地址)的连通性吗?为什么呢?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

34-Kubernetes网络模型与CNI网络插件

你好,我是张磊。今天我和你分享的主题是:Kubernetes网络模型与CNI网络插件。

在上一篇文章中,我以Flannel项目为例,为你详细讲解了容器跨主机网络的两种实现方法:UDP和VXLAN。

不难看到,这些例子有一个共性,那就是用户的容器都连接在docker0网桥上。而网络插件则在宿主机上创建了一个特殊的设备(UDP模式创建的是TUN设备,VXLAN模式创建的则是VTEP设备),docker0与这个设备之间,通过IP转发(路由表)进行协作。

然后,网络插件真正要做的事情,则是通过某种方法,把不同宿主机上的特殊设备连通,从而达到容器跨主机通信的目的。

实际上,上面这个流程,也正是Kubernetes对容器网络的主要处理方法。只不过,Kubernetes是通过一个叫作CNI的接口,维护了一个单独的网桥来代替docker0。这个网桥的名字就叫作:CNI网桥,它在宿主机上的设备名称默认是:cni0。

以Flannel的VXLAN模式为例,在Kubernetes环境里,它的工作方式跟我们在上一篇文章中讲解的没有任何不同。只不过,docker0网桥被替换成了CNI网桥而已,如下所示:

在这里,Kubernetes为Flannel分配的子网范围是10.244.0.0/16。这个参数可以在部署的时候指定,比如:

$ kubeadm init --pod-network-cidr=10.244.0.0/16

也可以在部署完成后,通过修改kube-controller-manager的配置文件来指定。

这时候,假设Infra-container-1要访问Infra-container-2(也就是Pod-1要访问Pod-2),这个IP包的源地址就是10.244.0.2,目的IP地址是10.244.1.3。而此时,Infra-container-1里的eth0设备,同样是以Veth Pair的方式连接在Node 1的cni0网桥上。所以这个IP包就会经过cni0网桥出现在宿主机上。

此时,Node 1上的路由表,如下所示:

# 在Node 1上
$ route -n
Kernel IP routing table
Destination     Gateway         Genmask         Flags Metric Ref    Use Iface
...
10.244.0.0      0.0.0.0         255.255.255.0   U     0      0        0 cni0
10.244.1.0      10.244.1.0      255.255.255.0   UG    0      0        0 flannel.1
172.17.0.0      0.0.0.0         255.255.0.0     U     0      0        0 docker0

因为我们的IP包的目的IP地址是10.244.1.3,所以它只能匹配到第二条规则,也就是10.244.1.0对应的这条路由规则。

可以看到,这条规则指定了本机的flannel.1设备进行处理。并且,flannel.1在处理完后,要将IP包转发到的网关(Gateway),正是“隧道”另一端的VTEP设备,也就是Node 2的flannel.1设备。所以,接下来的流程,就跟上一篇文章中介绍过的Flannel VXLAN模式完全一样了。

需要注意的是,CNI网桥只是接管所有CNI插件负责的、即Kubernetes创建的容器(Pod)。而此时,如果你用docker run单独启动一个容器,那么Docker项目还是会把这个容器连接到docker0网桥上。所以这个容器的IP地址,一定是属于docker0网桥的172.17.0.0/16网段。

Kubernetes之所以要设置这样一个与docker0网桥功能几乎一样的CNI网桥,主要原因包括两个方面:

  • 一方面,Kubernetes项目并没有使用Docker的网络模型(CNM),所以它并不希望、也不具备配置docker0网桥的能力;
  • 另一方面,这还与Kubernetes如何配置Pod,也就是Infra容器的Network Namespace密切相关。

我们知道,Kubernetes创建一个Pod的第一步,就是创建并启动一个Infra容器,用来“hold”住这个Pod的Network Namespace(这里,你可以再回顾一下专栏第13篇文章《为什么我们需要Pod?》中的相关内容)。

所以,CNI的设计思想,就是:Kubernetes在启动Infra容器之后,就可以直接调用CNI网络插件,为这个Infra容器的Network Namespace,配置符合预期的网络栈。

备注:在前面第32篇文章《浅谈容器网络》中,我讲解单机容器网络时,已经和你分享过,一个Network Namespace的网络栈包括:网卡(Network Interface)、回环设备(Loopback Device)、路由表(Routing Table)和iptables规则。

那么,这个网络栈的配置工作又是如何完成的呢?

为了回答这个问题,我们就需要从CNI插件的部署和实现方式谈起了。

我们在部署Kubernetes的时候,有一个步骤是安装kubernetes-cni包,它的目的就是在宿主机上安装CNI插件所需的基础可执行文件

在安装完成后,你可以在宿主机的/opt/cni/bin目录下看到它们,如下所示:

$ ls -al /opt/cni/bin/
total 73088
-rwxr-xr-x 1 root root  3890407 Aug 17  2017 bridge
-rwxr-xr-x 1 root root  9921982 Aug 17  2017 dhcp
-rwxr-xr-x 1 root root  2814104 Aug 17  2017 flannel
-rwxr-xr-x 1 root root  2991965 Aug 17  2017 host-local
-rwxr-xr-x 1 root root  3475802 Aug 17  2017 ipvlan
-rwxr-xr-x 1 root root  3026388 Aug 17  2017 loopback
-rwxr-xr-x 1 root root  3520724 Aug 17  2017 macvlan
-rwxr-xr-x 1 root root  3470464 Aug 17  2017 portmap
-rwxr-xr-x 1 root root  3877986 Aug 17  2017 ptp
-rwxr-xr-x 1 root root  2605279 Aug 17  2017 sample
-rwxr-xr-x 1 root root  2808402 Aug 17  2017 tuning
-rwxr-xr-x 1 root root  3475750 Aug 17  2017 vlan

这些CNI的基础可执行文件,按照功能可以分为三类:

第一类,叫作Main插件,它是用来创建具体网络设备的二进制文件。比如,bridge(网桥设备)、ipvlan、loopback(lo设备)、macvlan、ptp(Veth Pair设备),以及vlan。

我在前面提到过的Flannel、Weave等项目,都属于“网桥”类型的CNI插件。所以在具体的实现中,它们往往会调用bridge这个二进制文件。这个流程,我马上就会详细介绍到。

第二类,叫作IPAM(IP Address Management)插件,它是负责分配IP地址的二进制文件。比如,dhcp,这个文件会向DHCP服务器发起请求;host-local,则会使用预先配置的IP地址段来进行分配。

第三类,是由CNI社区维护的内置CNI插件。比如:flannel,就是专门为Flannel项目提供的CNI插件;tuning,是一个通过sysctl调整网络设备参数的二进制文件;portmap,是一个通过iptables配置端口映射的二进制文件;bandwidth,是一个使用Token Bucket Filter (TBF) 来进行限流的二进制文件。

从这些二进制文件中,我们可以看到,如果要实现一个给Kubernetes用的容器网络方案,其实需要做两部分工作,以Flannel项目为例:

首先,实现这个网络方案本身。这一部分需要编写的,其实就是flanneld进程里的主要逻辑。比如,创建和配置flannel.1设备、配置宿主机路由、配置ARP和FDB表里的信息等等。

然后,实现该网络方案对应的CNI插件。这一部分主要需要做的,就是配置Infra容器里面的网络栈,并把它连接在CNI网桥上。

由于Flannel项目对应的CNI插件已经被内置了,所以它无需再单独安装。而对于Weave、Calico等其他项目来说,我们就必须在安装插件的时候,把对应的CNI插件的可执行文件放在/opt/cni/bin/目录下。

实际上,对于Weave、Calico这样的网络方案来说,它们的DaemonSet只需要挂载宿主机的/opt/cni/bin/,就可以实现插件可执行文件的安装了。你可以想一下具体应该怎么做,就当作一个课后小问题留给你去实践了。

接下来,你就需要在宿主机上安装flanneld(网络方案本身)。而在这个过程中,flanneld启动后会在每台宿主机上生成它对应的CNI配置文件(它其实是一个ConfigMap),从而告诉Kubernetes,这个集群要使用Flannel作为容器网络方案。

这个CNI配置文件的内容如下所示:

$ cat /etc/cni/net.d/10-flannel.conflist
{
  "name": "cbr0",
  "plugins": [
    {
      "type": "flannel",
      "delegate": {
        "hairpinMode": true,
        "isDefaultGateway": true
      }
    },
    {
      "type": "portmap",
      "capabilities": {
        "portMappings": true
      }
    }
  ]
}

需要注意的是,在Kubernetes中,处理容器网络相关的逻辑并不会在kubelet主干代码里执行,而是会在具体的CRI(Container Runtime Interface,容器运行时接口)实现里完成。对于Docker项目来说,它的CRI实现叫作dockershim,你可以在kubelet的代码里找到它。

所以,接下来dockershim会加载上述的CNI配置文件。

需要注意,Kubernetes目前不支持多个CNI插件混用。如果你在CNI配置目录(/etc/cni/net.d)里放置了多个CNI配置文件的话,dockershim只会加载按字母顺序排序的第一个插件。

但另一方面,CNI允许你在一个CNI配置文件里,通过plugins字段,定义多个插件进行协作。

比如,在我们上面这个例子里,Flannel项目就指定了flannel和portmap这两个插件。

这时候,dockershim会把这个CNI配置文件加载起来,并且把列表里的第一个插件、也就是flannel插件,设置为默认插件。而在后面的执行过程中,flannel和portmap插件会按照定义顺序被调用,从而依次完成“配置容器网络”和“配置端口映射”这两步操作。

接下来,我就来为你讲解一下这样一个CNI插件的工作原理。

当kubelet组件需要创建Pod的时候,它第一个创建的一定是Infra容器。所以在这一步,dockershim就会先调用Docker API创建并启动Infra容器,紧接着执行一个叫作SetUpPod的方法。这个方法的作用就是:为CNI插件准备参数,然后调用CNI插件为Infra容器配置网络。

这里要调用的CNI插件,就是/opt/cni/bin/flannel;而调用它所需要的参数,分为两部分。

第一部分,是由dockershim设置的一组CNI环境变量。

其中,最重要的环境变量参数叫作:CNI_COMMAND。它的取值只有两种:ADD和DEL。

这个ADD和DEL操作,就是CNI插件唯一需要实现的两个方法。

其中ADD操作的含义是:把容器添加到CNI网络里;DEL操作的含义则是:把容器从CNI网络里移除掉。

而对于网桥类型的CNI插件来说,这两个操作意味着把容器以Veth Pair的方式“插”到CNI网桥上,或者从网桥上“拔”掉。

接下来,我以ADD操作为重点进行讲解。

CNI的ADD操作需要的参数包括:容器里网卡的名字eth0(CNI_IFNAME)、Pod的Network Namespace文件的路径(CNI_NETNS)、容器的ID(CNI_CONTAINERID)等。这些参数都属于上述环境变量里的内容。其中,Pod(Infra容器)的Network Namespace文件的路径,我在前面讲解容器基础的时候提到过,即:/proc/<容器进程的PID>/ns/net。

备注:这里你也可以再回顾下专栏第8篇文章《白话容器基础(四):重新认识Docker容器》中的相关内容。

除此之外,在 CNI 环境变量里,还有一个叫作CNI_ARGS的参数。通过这个参数,CRI实现(比如dockershim)就可以以Key-Value的格式,传递自定义信息给网络插件。这是用户将来自定义CNI协议的一个重要方法。

第二部分,则是dockershim从CNI配置文件里加载到的、默认插件的配置信息。

这个配置信息在CNI中被叫作Network Configuration,它的完整定义你可以参考这个文档。dockershim会把Network Configuration以JSON数据的格式,通过标准输入(stdin)的方式传递给Flannel CNI插件。

而有了这两部分参数,Flannel CNI插件实现ADD操作的过程就非常简单了。

不过,需要注意的是,Flannel的CNI配置文件( /etc/cni/net.d/10-flannel.conflist)里有这么一个字段,叫作delegate:

...
     "delegate": {
        "hairpinMode": true,
        "isDefaultGateway": true
      }

Delegate字段的意思是,这个CNI插件并不会自己做事儿,而是会调用Delegate指定的某种CNI内置插件来完成。对于Flannel来说,它调用的Delegate插件,就是前面介绍到的CNI bridge插件。

所以说,dockershim对Flannel CNI插件的调用,其实就是走了个过场。Flannel CNI插件唯一需要做的,就是对dockershim传来的Network Configuration进行补充。比如,将Delegate的Type字段设置为bridge,将Delegate的IPAM字段设置为host-local等。

经过Flannel CNI插件补充后的、完整的Delegate字段如下所示:

{
    "hairpinMode":true,
    "ipMasq":false,
    "ipam":{
        "routes":[
            {
                "dst":"10.244.0.0/16"
            }
        ],
        "subnet":"10.244.1.0/24",
        "type":"host-local"
    },
    "isDefaultGateway":true,
    "isGateway":true,
    "mtu":1410,
    "name":"cbr0",
    "type":"bridge"
}

其中,ipam字段里的信息,比如10.244.1.0/24,读取自Flannel在宿主机上生成的Flannel配置文件,即:宿主机上的/run/flannel/subnet.env文件。

接下来,Flannel CNI插件就会调用CNI bridge插件,也就是执行:/opt/cni/bin/bridge二进制文件。

这一次,调用CNI bridge插件需要的两部分参数的第一部分、也就是CNI环境变量,并没有变化。所以,它里面的CNI_COMMAND参数的值还是“ADD”。

而第二部分Network Configration,正是上面补充好的Delegate字段。Flannel CNI插件会把Delegate字段的内容以标准输入(stdin)的方式传递给CNI bridge插件。

此外,Flannel CNI插件还会把Delegate字段以JSON文件的方式,保存在/var/lib/cni/flannel目录下。这是为了给后面删除容器调用DEL操作时使用的。

有了这两部分参数,接下来CNI bridge插件就可以“代表”Flannel,进行“将容器加入到CNI网络里”这一步操作了。而这一部分内容,与容器Network Namespace密切相关,所以我要为你详细讲解一下。

首先,CNI bridge插件会在宿主机上检查CNI网桥是否存在。如果没有的话,那就创建它。这相当于在宿主机上执行:

# 在宿主机上
$ ip link add cni0 type bridge
$ ip link set cni0 up

接下来,CNI bridge插件会通过Infra容器的Network Namespace文件,进入到这个Network Namespace里面,然后创建一对Veth Pair设备。

紧接着,它会把这个Veth Pair的其中一端,“移动”到宿主机上。这相当于在容器里执行如下所示的命令:

#在容器里

# 创建一对Veth Pair设备。其中一个叫作eth0,另一个叫作vethb4963f3
$ ip link add eth0 type veth peer name vethb4963f3

# 启动eth0设备
$ ip link set eth0 up

# 将Veth Pair设备的另一端(也就是vethb4963f3设备)放到宿主机(也就是Host Namespace)里
$ ip link set vethb4963f3 netns $HOST_NS

# 通过Host Namespace,启动宿主机上的vethb4963f3设备
$ ip netns exec $HOST_NS ip link set vethb4963f3 up

这样,vethb4963f3就出现在了宿主机上,而且这个Veth Pair设备的另一端,就是容器里面的eth0。

当然,你可能已经想到,上述创建Veth Pair设备的操作,其实也可以先在宿主机上执行,然后再把该设备的一端放到容器的Network Namespace里,这个原理是一样的。

不过,CNI插件之所以要“反着”来,是因为CNI里对Namespace操作函数的设计就是如此,如下所示:

err := containerNS.Do(func(hostNS ns.NetNS) error {
  ...
  return nil
})

这个设计其实很容易理解。在编程时,容器的Namespace是可以直接通过Namespace文件拿到的;而Host Namespace,则是一个隐含在上下文的参数。所以,像上面这样,先通过容器Namespace进入容器里面,然后再反向操作Host Namespace,对于编程来说要更加方便。

接下来,CNI bridge插件就可以把vethb4963f3设备连接在CNI网桥上。这相当于在宿主机上执行:

# 在宿主机上
$ ip link set vethb4963f3 master cni0

在将vethb4963f3设备连接在CNI网桥之后,CNI bridge插件还会为它设置Hairpin Mode(发夹模式)。这是因为,在默认情况下,网桥设备是不允许一个数据包从一个端口进来后,再从这个端口发出去的。但是,它允许你为这个端口开启Hairpin Mode,从而取消这个限制。

这个特性,主要用在容器需要通过NAT(即:端口映射)的方式,“自己访问自己”的场景下。

举个例子,比如我们执行docker run -p 8080:80,就是在宿主机上通过iptables设置了一条DNAT(目的地址转换)转发规则。这条规则的作用是,当宿主机上的进程访问“<宿主机的IP地址>:8080”时,iptables会把该请求直接转发到“<容器的IP地址>:80”上。也就是说,这个请求最终会经过docker0网桥进入容器里面。

但如果你是在容器里面访问宿主机的8080端口,那么这个容器里发出的IP包会经过vethb4963f3设备(端口)和docker0网桥,来到宿主机上。此时,根据上述DNAT规则,这个IP包又需要回到docker0网桥,并且还是通过vethb4963f3端口进入到容器里。所以,这种情况下,我们就需要开启vethb4963f3端口的Hairpin Mode了。

所以说,Flannel插件要在CNI配置文件里声明hairpinMode=true。这样,将来这个集群里的Pod才可以通过它自己的Service访问到自己。

接下来,CNI bridge插件会调用CNI ipam插件,从ipam.subnet字段规定的网段里为容器分配一个可用的IP地址。然后,CNI bridge插件就会把这个IP地址添加在容器的eth0网卡上,同时为容器设置默认路由。这相当于在容器里执行:

# 在容器里
$ ip addr add 10.244.0.2/24 dev eth0
$ ip route add default via 10.244.0.1 dev eth0

最后,CNI bridge插件会为CNI网桥添加IP地址。这相当于在宿主机上执行:

# 在宿主机上
$ ip addr add 10.244.0.1/24 dev cni0

在执行完上述操作之后,CNI插件会把容器的IP地址等信息返回给dockershim,然后被kubelet添加到Pod的Status字段。

至此,CNI插件的ADD方法就宣告结束了。接下来的流程,就跟我们上一篇文章中容器跨主机通信的过程完全一致了。

需要注意的是,对于非网桥类型的CNI插件,上述“将容器添加到CNI网络”的操作流程,以及网络方案本身的工作原理,就都不太一样了。我将会在后续文章中,继续为你分析这部分内容。

总结

在本篇文章中,我为你详细讲解了Kubernetes中CNI网络的实现原理。根据这个原理,你其实就很容易理解所谓的“Kubernetes网络模型”了:

  1. 所有容器都可以直接使用IP地址与其他容器通信,而无需使用NAT。

  2. 所有宿主机都可以直接使用IP地址与所有容器通信,而无需使用NAT。反之亦然。

  3. 容器自己“看到”的自己的IP地址,和别人(宿主机或者容器)看到的地址是完全一样的。

可以看到,这个网络模型,其实可以用一个字总结,那就是“通”。

容器与容器之间要“通”,容器与宿主机之间也要“通”。并且,Kubernetes要求这个“通”,还必须是直接基于容器和宿主机的IP地址来进行的。

当然,考虑到不同用户之间的隔离性,在很多场合下,我们还要求容器之间的网络“不通”。这个问题,我会在后面的文章中会为你解决。

思考题

请你思考一下,为什么Kubernetes项目不自己实现容器网络,而是要通过 CNI 做一个如此简单的假设呢?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

35-解读Kubernetes三层网络方案

你好,我是张磊。今天我和你分享的主题是:解读Kubernetes三层网络方案。

在上一篇文章中,我以网桥类型的Flannel插件为例,为你讲解了Kubernetes里容器网络和CNI插件的主要工作原理。不过,除了这种模式之外,还有一种纯三层(Pure Layer 3)网络方案非常值得你注意。其中的典型例子,莫过于Flannel的host-gw模式和Calico项目了。

我们先来看一下Flannel的host-gw模式。

它的工作原理非常简单,我用一张图就可以和你说清楚。为了方便叙述,接下来我会称这张图为“host-gw示意图”。

假设现在,Node 1上的Infra-container-1,要访问Node 2上的Infra-container-2。

当你设置Flannel使用host-gw模式之后,flanneld会在宿主机上创建这样一条规则,以Node 1为例:

$ ip route
...
10.244.1.0/24 via 10.168.0.3 dev eth0

这条路由规则的含义是:目的IP地址属于10.244.1.0/24网段的IP包,应该经过本机的eth0设备发出去(即:dev eth0);并且,它下一跳地址(next-hop)是10.168.0.3(即:via 10.168.0.3)。

所谓下一跳地址就是:如果IP包从主机A发到主机B,需要经过路由设备X的中转。那么X的IP地址就应该配置为主机A的下一跳地址。

而从host-gw示意图中我们可以看到,这个下一跳地址对应的,正是我们的目的宿主机Node 2。

一旦配置了下一跳地址,那么接下来,当IP包从网络层进入链路层封装成帧的时候,eth0设备就会使用下一跳地址对应的MAC地址,作为该数据帧的目的MAC地址。显然,这个MAC地址,正是Node 2的MAC地址。

这样,这个数据帧就会从Node 1通过宿主机的二层网络顺利到达Node 2上。

而Node 2的内核网络栈从二层数据帧里拿到IP包后,会“看到”这个IP包的目的IP地址是10.244.1.3,即Infra-container-2的IP地址。这时候,根据Node 2上的路由表,该目的地址会匹配到第二条路由规则(也就是10.244.1.0对应的路由规则),从而进入cni0网桥,进而进入到Infra-container-2当中。

可以看到,host-gw模式的工作原理,其实就是将每个Flannel子网(Flannel Subnet,比如:10.244.1.0/24)的“下一跳”,设置成了该子网对应的宿主机的IP地址。

也就是说,这台“主机”(Host)会充当这条容器通信路径里的“网关”(Gateway)。这也正是“host-gw”的含义。

当然,Flannel子网和主机的信息,都是保存在Etcd当中的。flanneld只需要WACTH这些数据的变化,然后实时更新路由表即可。

注意:在Kubernetes v1.7之后,类似Flannel、Calico的CNI网络插件都是可以直接连接Kubernetes的APIServer来访问Etcd的,无需额外部署Etcd给它们使用。

而在这种模式下,容器通信的过程就免除了额外的封包和解包带来的性能损耗。根据实际的测试,host-gw的性能损失大约在10%左右,而其他所有基于VXLAN“隧道”机制的网络方案,性能损失都在20%~30%左右。

当然,通过上面的叙述,你也应该看到,host-gw模式能够正常工作的核心,就在于IP包在封装成帧发送出去的时候,会使用路由表里的“下一跳”来设置目的MAC地址。这样,它就会经过二层网络到达目的宿主机。

所以说,Flannel host-gw模式必须要求集群宿主机之间是二层连通的。

需要注意的是,宿主机之间二层不连通的情况也是广泛存在的。比如,宿主机分布在了不同的子网(VLAN)里。但是,在一个Kubernetes集群里,宿主机之间必须可以通过IP地址进行通信,也就是说至少是三层可达的。否则的话,你的集群将不满足上一篇文章中提到的宿主机之间IP互通的假设(Kubernetes网络模型)。当然,“三层可达”也可以通过为几个子网设置三层转发来实现。

而在容器生态中,要说到像Flannel host-gw这样的三层网络方案,我们就不得不提到这个领域里的“龙头老大”Calico项目了。

实际上,Calico项目提供的网络解决方案,与Flannel的host-gw模式,几乎是完全一样的。也就是说,Calico也会在每台宿主机上,添加一个格式如下所示的路由规则:

<目的容器IP地址段> via <网关的IP地址> dev eth0

其中,网关的IP地址,正是目的容器所在宿主机的IP地址。

而正如前所述,这个三层网络方案得以正常工作的核心,是为每个容器的IP地址,找到它所对应的、“下一跳”的网关

不过,不同于Flannel通过Etcd和宿主机上的flanneld来维护路由信息的做法,Calico项目使用了一个“重型武器”来自动地在整个集群中分发路由信息。

这个“重型武器”,就是BGP。

BGP的全称是Border Gateway Protocol,即:边界网关协议。它是一个Linux内核原生就支持的、专门用在大规模数据中心里维护不同的“自治系统”之间路由信息的、无中心的路由协议。

这个概念可能听起来有点儿“吓人”,但实际上,我可以用一个非常简单的例子来为你讲清楚。

在这个图中,我们有两个自治系统(Autonomous System,简称为AS):AS 1和AS 2。而所谓的一个自治系统,指的是一个组织管辖下的所有IP网络和路由器的全体。你可以把它想象成一个小公司里的所有主机和路由器。在正常情况下,自治系统之间不会有任何“来往”。

但是,如果这样两个自治系统里的主机,要通过IP地址直接进行通信,我们就必须使用路由器把这两个自治系统连接起来。

比如,AS 1里面的主机10.10.0.2,要访问AS 2里面的主机172.17.0.3的话。它发出的IP包,就会先到达自治系统AS 1上的路由器 Router 1。

而在此时,Router 1的路由表里,有这样一条规则,即:目的地址是172.17.0.2包,应该经过Router 1的C接口,发往网关Router 2(即:自治系统AS 2上的路由器)。

所以IP包就会到达Router 2上,然后经过Router 2的路由表,从B接口出来到达目的主机172.17.0.3。

但是反过来,如果主机172.17.0.3要访问10.10.0.2,那么这个IP包,在到达Router 2之后,就不知道该去哪儿了。因为在Router 2的路由表里,并没有关于AS 1自治系统的任何路由规则。

所以这时候,网络管理员就应该给Router 2也添加一条路由规则,比如:目标地址是10.10.0.2的IP包,应该经过Router 2的C接口,发往网关Router 1。

像上面这样负责把自治系统连接在一起的路由器,我们就把它形象地称为:边界网关。它跟普通路由器的不同之处在于,它的路由表里拥有其他自治系统里的主机路由信息。

上面的这部分原理,相信你理解起来应该很容易。毕竟,路由器这个设备本身的主要作用,就是连通不同的网络。

但是,你可以想象一下,假设我们现在的网络拓扑结构非常复杂,每个自治系统都有成千上万个主机、无数个路由器,甚至是由多个公司、多个网络提供商、多个自治系统组成的复合自治系统呢?

这时候,如果还要依靠人工来对边界网关的路由表进行配置和维护,那是绝对不现实的。

而这种情况下,BGP大显身手的时刻就到了。

在使用了BGP之后,你可以认为,在每个边界网关上都会运行着一个小程序,它们会将各自的路由表信息,通过TCP传输给其他的边界网关。而其他边界网关上的这个小程序,则会对收到的这些数据进行分析,然后将需要的信息添加到自己的路由表里。

这样,图2中Router 2的路由表里,就会自动出现10.10.0.2和10.10.0.3对应的路由规则了。

所以说,所谓BGP,就是在大规模网络中实现节点路由信息共享的一种协议。

而BGP的这个能力,正好可以取代Flannel维护主机上路由表的功能。而且,BGP这种原生就是为大规模网络环境而实现的协议,其可靠性和可扩展性,远非Flannel自己的方案可比。

需要注意的是,BGP协议实际上是最复杂的一种路由协议。我在这里的讲述和所举的例子,仅是为了能够帮助你建立对BGP的感性认识,并不代表BGP真正的实现方式。

接下来,我们还是回到Calico项目上来。

在了解了BGP之后,Calico项目的架构就非常容易理解了。它由三个部分组成:

  1. Calico的CNI插件。这是Calico与Kubernetes对接的部分。我已经在上一篇文章中,和你详细分享了CNI插件的工作原理,这里就不再赘述了。

  2. Felix。它是一个DaemonSet,负责在宿主机上插入路由规则(即:写入Linux内核的FIB转发信息库),以及维护Calico所需的网络设备等工作。

  3. BIRD。它就是BGP的客户端,专门负责在集群里分发路由规则信息。

除了对路由信息的维护方式之外,Calico项目与Flannel的host-gw模式的另一个不同之处,就是它不会在宿主机上创建任何网桥设备。这时候,Calico的工作方式,可以用一幅示意图来描述,如下所示(在接下来的讲述中,我会统一用“BGP示意图”来指代它):

其中的绿色实线标出的路径,就是一个IP包从Node 1上的Container 1,到达Node 2上的Container 4的完整路径。

可以看到,Calico的CNI插件会为每个容器设置一个Veth Pair设备,然后把其中的一端放置在宿主机上(它的名字以cali前缀开头)。

此外,由于Calico没有使用CNI的网桥模式,Calico的CNI插件还需要在宿主机上为每个容器的Veth Pair设备配置一条路由规则,用于接收传入的IP包。比如,宿主机Node 2上的Container 4对应的路由规则,如下所示:

10.233.2.3 dev cali5863f3 scope link

即:发往10.233.2.3的IP包,应该进入cali5863f3设备。

基于上述原因,Calico项目在宿主机上设置的路由规则,肯定要比Flannel项目多得多。不过,Flannel host-gw模式使用CNI网桥的主要原因,其实是为了跟VXLAN模式保持一致。否则的话,Flannel就需要维护两套CNI插件了。

有了这样的Veth Pair设备之后,容器发出的IP包就会经过Veth Pair设备出现在宿主机上。然后,宿主机网络栈就会根据路由规则的下一跳IP地址,把它们转发给正确的网关。接下来的流程就跟Flannel host-gw模式完全一致了。

其中,这里最核心的“下一跳”路由规则,就是由Calico的Felix进程负责维护的。这些路由规则信息,则是通过BGP Client也就是BIRD组件,使用BGP协议传输而来的。

而这些通过BGP协议传输的消息,你可以简单地理解为如下格式:

[BGP消息]
我是宿主机192.168.1.3
10.233.2.0/24网段的容器都在我这里
这些容器的下一跳地址是我

不难发现,Calico项目实际上将集群里的所有节点,都当作是边界路由器来处理,它们一起组成了一个全连通的网络,互相之间通过BGP协议交换路由规则。这些节点,我们称为BGP Peer。

需要注意的是,Calico维护的网络在默认配置下,是一个被称为“Node-to-Node Mesh”的模式。这时候,每台宿主机上的BGP Client都需要跟其他所有节点的BGP Client进行通信以便交换路由信息。但是,随着节点数量N的增加,这些连接的数量就会以N²的规模快速增长,从而给集群本身的网络带来巨大的压力。

所以,Node-to-Node Mesh模式一般推荐用在少于100个节点的集群里。而在更大规模的集群中,你需要用到的是一个叫作Route Reflector的模式。

在这种模式下,Calico会指定一个或者几个专门的节点,来负责跟所有节点建立BGP连接从而学习到全局的路由规则。而其他节点,只需要跟这几个专门的节点交换路由信息,就可以获得整个集群的路由规则信息了。

这些专门的节点,就是所谓的Route Reflector节点,它们实际上扮演了“中间代理”的角色,从而把BGP连接的规模控制在N的数量级上。

此外,我在前面提到过,Flannel host-gw模式最主要的限制,就是要求集群宿主机之间是二层连通的。而这个限制对于Calico来说,也同样存在。

举个例子,假如我们有两台处于不同子网的宿主机Node 1和Node 2,对应的IP地址分别是192.168.1.2和192.168.2.2。需要注意的是,这两台机器通过路由器实现了三层转发,所以这两个IP地址之间是可以相互通信的。

而我们现在的需求,还是Container 1要访问Container 4。

按照我们前面的讲述,Calico会尝试在Node 1上添加如下所示的一条路由规则:

10.233.2.0/16 via 192.168.2.2 eth0

但是,这时候问题就来了。

上面这条规则里的下一跳地址是192.168.2.2,可是它对应的Node 2跟Node 1却根本不在一个子网里,没办法通过二层网络把IP包发送到下一跳地址。

在这种情况下,你就需要为Calico打开IPIP模式。

我把这个模式下容器通信的原理,总结成了一张图片,如下所示(接下来我会称之为:IPIP示意图):

在Calico的IPIP模式下,Felix进程在Node 1上添加的路由规则,会稍微不同,如下所示:

10.233.2.0/24 via 192.168.2.2 tunl0

可以看到,尽管这条规则的下一跳地址仍然是Node 2的IP地址,但这一次,要负责将IP包发出去的设备,变成了tunl0。注意,是T-U-N-L-0,而不是Flannel UDP模式使用的T-U-N-0(tun0),这两种设备的功能是完全不一样的。

Calico使用的这个tunl0设备,是一个IP隧道(IP tunnel)设备。

在上面的例子中,IP包进入IP隧道设备之后,就会被Linux内核的IPIP驱动接管。IPIP驱动会将这个IP包直接封装在一个宿主机网络的IP包中,如下所示:

图5 IPIP封包方式

其中,经过封装后的新的IP包的目的地址(图5中的Outer IP Header部分),正是原IP包的下一跳地址,即Node 2的IP地址:192.168.2.2。

而原IP包本身,则会被直接封装成新IP包的Payload。

这样,原先从容器到Node 2的IP包,就被伪装成了一个从Node 1到Node 2的IP包。

由于宿主机之间已经使用路由器配置了三层转发,也就是设置了宿主机之间的“下一跳”。所以这个IP包在离开Node 1之后,就可以经过路由器,最终“跳”到Node 2上。

这时,Node 2的网络内核栈会使用IPIP驱动进行解包,从而拿到原始的IP包。然后,原始IP包就会经过路由规则和Veth Pair设备到达目的容器内部。

以上,就是Calico项目主要的工作原理了。

不难看到,当Calico使用IPIP模式的时候,集群的网络性能会因为额外的封包和解包工作而下降。在实际测试中,Calico IPIP模式与Flannel VXLAN模式的性能大致相当。所以,在实际使用时,如非硬性需求,我建议你将所有宿主机节点放在一个子网里,避免使用IPIP。

不过,通过上面对Calico工作原理的讲述,你应该能发现这样一个事实:

如果Calico项目能够让宿主机之间的路由设备(也就是网关),也通过BGP协议“学习”到Calico网络里的路由规则,那么从容器发出的IP包,不就可以通过这些设备路由到目的宿主机了么?

比如,只要在上面“IPIP示意图”中的Node 1上,添加如下所示的一条路由规则:

10.233.2.0/24 via 192.168.1.1 eth0

然后,在Router 1上(192.168.1.1),添加如下所示的一条路由规则:

10.233.2.0/24 via 192.168.2.1 eth0

那么Container 1发出的IP包,就可以通过两次“下一跳”,到达Router 2(192.168.2.1)了。以此类推,我们可以继续在Router 2上添加“下一条”路由,最终把IP包转发到Node 2上。

遗憾的是,上述流程虽然简单明了,但是在Kubernetes被广泛使用的公有云场景里,却完全不可行。

这里的原因在于:公有云环境下,宿主机之间的网关,肯定不会允许用户进行干预和设置。

当然,在大多数公有云环境下,宿主机(公有云提供的虚拟机)本身往往就是二层连通的,所以这个需求也不强烈。

不过,在私有部署的环境下,宿主机属于不同子网(VLAN)反而是更加常见的部署状态。这时候,想办法将宿主机网关也加入到BGP Mesh里从而避免使用IPIP,就成了一个非常迫切的需求。

而在Calico项目中,它已经为你提供了两种将宿主机网关设置成BGP Peer的解决方案。

第一种方案,就是所有宿主机都跟宿主机网关建立BGP Peer关系。

这种方案下,Node 1和Node 2就需要主动跟宿主机网关Router 1和Router 2建立BGP连接。从而将类似于10.233.2.0/24这样的路由信息同步到网关上去。

需要注意的是,这种方式下,Calico要求宿主机网关必须支持一种叫作Dynamic Neighbors的BGP配置方式。这是因为,在常规的路由器BGP配置里,运维人员必须明确给出所有BGP Peer的IP地址。考虑到Kubernetes集群可能会有成百上千个宿主机,而且还会动态地添加和删除节点,这时候再手动管理路由器的BGP配置就非常麻烦了。而Dynamic Neighbors则允许你给路由器配置一个网段,然后路由器就会自动跟该网段里的主机建立起BGP Peer关系。

不过,相比之下,我更愿意推荐第二种方案

这种方案,是使用一个或多个独立组件负责搜集整个集群里的所有路由信息,然后通过BGP协议同步给网关。而我们前面提到,在大规模集群中,Calico本身就推荐使用Route Reflector节点的方式进行组网。所以,这里负责跟宿主机网关进行沟通的独立组件,直接由Route Reflector兼任即可。

更重要的是,这种情况下网关的BGP Peer个数是有限并且固定的。所以我们就可以直接把这些独立组件配置成路由器的BGP Peer,而无需Dynamic Neighbors的支持。

当然,这些独立组件的工作原理也很简单:它们只需要WATCH Etcd里的宿主机和对应网段的变化信息,然后把这些信息通过BGP协议分发给网关即可。

总结

在本篇文章中,我为你详细讲述了Fannel host-gw模式和Calico这两种纯三层网络方案的工作原理。

需要注意的是,在大规模集群里,三层网络方案在宿主机上的路由规则可能会非常多,这会导致错误排查变得困难。此外,在系统故障的时候,路由规则出现重叠冲突的概率也会变大。

基于上述原因,如果是在公有云上,由于宿主机网络本身比较“直白”,我一般会推荐更加简单的Flannel host-gw模式。

但不难看到,在私有部署环境里,Calico项目才能够覆盖更多的场景,并为你提供更加可靠的组网方案和架构思路。

思考题

你能否能总结一下三层网络方案和“隧道模式”的异同,以及各自的优缺点?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

36-为什么说Kubernetes只有softmulti-tenancy?

你好,我是张磊。今天我和你分享的主题是:为什么说Kubernetes只有soft multi-tenancy?

在前面的文章中,我为你详细讲解了Kubernetes生态里,主流容器网络方案的工作原理。

不难发现,Kubernetes的网络模型,以及前面这些网络方案的实现,都只关注容器之间网络的“连通”,却并不关心容器之间网络的“隔离”。这跟传统的IaaS层的网络方案,区别非常明显。

你肯定会问了,Kubernetes的网络方案对“隔离”到底是如何考虑的呢?难道Kubernetes就不管网络“多租户”的需求吗?

接下来,在今天这篇文章中,我就来回答你的这些问题。

在Kubernetes里,网络隔离能力的定义,是依靠一种专门的API对象来描述的,即:NetworkPolicy。

一个完整的NetworkPolicy对象的示例,如下所示:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: test-network-policy
  namespace: default
spec:
  podSelector:
    matchLabels:
      role: db
  policyTypes:
  - Ingress
  - Egress
  ingress:
  - from:
    - ipBlock:
        cidr: 172.17.0.0/16
        except:
        - 172.17.1.0/24
    - namespaceSelector:
        matchLabels:
          project: myproject
    - podSelector:
        matchLabels:
          role: frontend
    ports:
    - protocol: TCP
      port: 6379
  egress:
  - to:
    - ipBlock:
        cidr: 10.0.0.0/24
    ports:
    - protocol: TCP
      port: 5978

我在和你分享前面的内容时已经说过(这里你可以再回顾下第34篇文章Kubernetes 网络模型与 CNI 网络插件中的相关内容),Kubernetes里的Pod默认都是“允许所有”(Accept All)的,即:Pod可以接收来自任何发送方的请求;或者,向任何接收方发送请求。而如果你要对这个情况作出限制,就必须通过NetworkPolicy对象来指定。

而在上面这个例子里,你首先会看到podSelector字段。它的作用,就是定义这个NetworkPolicy的限制范围,比如:当前Namespace里携带了role=db标签的Pod。

而如果你把podSelector字段留空:

spec:
 podSelector: {}

那么这个NetworkPolicy就会作用于当前Namespace下的所有Pod。

而一旦Pod被NetworkPolicy选中,那么这个Pod就会进入“拒绝所有”(Deny All)的状态,即:这个Pod既不允许被外界访问,也不允许对外界发起访问。

而NetworkPolicy定义的规则,其实就是“白名单”。

例如,在我们上面这个例子里,我在policyTypes字段,定义了这个NetworkPolicy的类型是ingress和egress,即:它既会影响流入(ingress)请求,也会影响流出(egress)请求。

然后,在ingress字段里,我定义了from和ports,即:允许流入的“白名单”和端口。其中,这个允许流入的“白名单”里,我指定了三种并列的情况,分别是:ipBlock、namespaceSelector和podSelector。

而在egress字段里,我则定义了to和ports,即:允许流出的“白名单”和端口。这里允许流出的“白名单”的定义方法与ingress类似。只不过,这一次ipblock字段指定的,是目的地址的网段。

综上所述,这个NetworkPolicy对象,指定的隔离规则如下所示:

  1. 该隔离规则只对default Namespace下的,携带了role=db标签的Pod有效。限制的请求类型包括ingress(流入)和egress(流出)。
  2. Kubernetes会拒绝任何访问被隔离Pod的请求,除非这个请求来自于以下“白名单”里的对象,并且访问的是被隔离Pod的6379端口。这些“白名单”对象包括:
    a. default Namespace里的,携带了role=fronted标签的Pod;
    b. 携带了project=myproject 标签的 Namespace 里的任何 Pod;
    c. 任何源地址属于172.17.0.0/16网段,且不属于172.17.1.0/24网段的请求。
  3. Kubernetes会拒绝被隔离Pod对外发起任何请求,除非请求的目的地址属于10.0.0.0/24网段,并且访问的是该网段地址的5978端口。

需要注意的是,定义一个NetworkPolicy对象的过程,容易犯错的是“白名单”部分(from和to字段)。

举个例子:

  ...
  ingress:
  - from:
    - namespaceSelector:
        matchLabels:
          user: alice
    - podSelector:
        matchLabels:
          role: client
  ...

像上面这样定义的namespaceSelector和podSelector,是“或”(OR)的关系。所以说,这个from字段定义了两种情况,无论是Namespace满足条件,还是Pod满足条件,这个NetworkPolicy都会生效。

而下面这个例子,虽然看起来类似,但是它定义的规则却完全不同:

...
  ingress:
  - from:
    - namespaceSelector:
        matchLabels:
          user: alice
      podSelector:
        matchLabels:
          role: client
  ...

注意看,这样定义的namespaceSelector和podSelector,其实是“与”(AND)的关系。所以说,这个from字段只定义了一种情况,只有Namespace和Pod同时满足条件,这个NetworkPolicy才会生效。

这两种定义方式的区别,请你一定要分清楚。

此外,如果要使上面定义的NetworkPolicy在Kubernetes集群里真正产生作用,你的CNI网络插件就必须是支持Kubernetes的NetworkPolicy的。

在具体实现上,凡是支持NetworkPolicy的CNI网络插件,都维护着一个NetworkPolicy Controller,通过控制循环的方式对NetworkPolicy对象的增删改查做出响应,然后在宿主机上完成iptables规则的配置工作。

在Kubernetes生态里,目前已经实现了NetworkPolicy的网络插件包括Calico、Weave和kube-router等多个项目,但是并不包括Flannel项目。

所以说,如果想要在使用Flannel的同时还使用NetworkPolicy的话,你就需要再额外安装一个网络插件,比如Calico项目,来负责执行NetworkPolicy。

安装Flannel + Calico的流程非常简单,你直接参考这个文档一键安装即可。

那么,这些网络插件,又是如何根据NetworkPolicy对Pod进行隔离的呢?

接下来,我就以三层网络插件为例(比如Calico和kube-router),来为你分析一下这部分的原理。

为了方便讲解,这一次我编写了一个比较简单的NetworkPolicy对象,如下所示:

apiVersion: extensions/v1beta1
kind: NetworkPolicy
metadata:
  name: test-network-policy
  namespace: default
spec:
  podSelector:
    matchLabels:
      role: db
  ingress:
   - from:
     - namespaceSelector:
         matchLabels:
           project: myproject
     - podSelector:
         matchLabels:
           role: frontend
     ports:
       - protocol: tcp
         port: 6379

可以看到,我们指定的ingress“白名单”,是任何Namespace里,携带project=myproject标签的Namespace里的Pod;以及default Namespace里,携带了role=frontend标签的Pod。允许被访问的端口是:6379。

而被隔离的对象,是所有携带了role=db标签的Pod。

那么这个时候,Kubernetes的网络插件就会使用这个NetworkPolicy的定义,在宿主机上生成iptables规则。这个过程,我可以通过如下所示的一段Go语言风格的伪代码来为你描述:

for dstIP := range 所有被networkpolicy.spec.podSelector选中的Pod的IP地址
  for srcIP := range 所有被ingress.from.podSelector选中的Pod的IP地址
    for port, protocol := range ingress.ports {
      iptables -A KUBE-NWPLCY-CHAIN -s $srcIP -d $dstIP -p $protocol -m $protocol --dport $port -j ACCEPT
    }
  }
}

可以看到,这是一条最基本的、通过匹配条件决定下一步动作的iptables规则。

这条规则的名字是KUBE-NWPLCY-CHAIN,含义是:当IP包的源地址是srcIP、目的地址是dstIP、协议是protocol、目的端口是port的时候,就允许它通过(ACCEPT)。

而正如这段伪代码所示,匹配这条规则所需的这四个参数,都是从NetworkPolicy对象里读取出来的。

可以看到,Kubernetes网络插件对Pod进行隔离,其实是靠在宿主机上生成NetworkPolicy对应的iptable规则来实现的。

此外,在设置好上述“隔离”规则之后,网络插件还需要想办法,将所有对被隔离Pod的访问请求,都转发到上述KUBE-NWPLCY-CHAIN规则上去进行匹配。并且,如果匹配不通过,这个请求应该被“拒绝”。

在CNI网络插件中,上述需求可以通过设置两组iptables规则来实现。

第一组规则,负责“拦截”对被隔离Pod的访问请求。生成这一组规则的伪代码,如下所示:

for pod := range 该Node上的所有Pod {
    if pod是networkpolicy.spec.podSelector选中的 {
        iptables -A FORWARD -d $podIP -m physdev --physdev-is-bridged -j KUBE-POD-SPECIFIC-FW-CHAIN
        iptables -A FORWARD -d $podIP -j KUBE-POD-SPECIFIC-FW-CHAIN
        ...
    }
}

可以看到,这里的的iptables规则使用到了内置链:FORWARD。它是什么意思呢?

说到这里,我就得为你稍微普及一下iptables的知识了。

实际上,iptables只是一个操作Linux内核Netfilter子系统的“界面”。顾名思义,Netfilter子系统的作用,就是Linux内核里挡在“网卡”和“用户态进程”之间的一道“防火墙”。它们的关系,可以用如下的示意图来表示:


可以看到,这幅示意图中,IP包“一进一出”的两条路径上,有几个关键的“检查点”,它们正是Netfilter设置“防火墙”的地方。在iptables中,这些“检查点”被称为:链(Chain)。这是因为这些“检查点”对应的iptables规则,是按照定义顺序依次进行匹配的。这些“检查点”的具体工作原理,可以用如下所示的示意图进行描述:

可以看到,当一个IP包通过网卡进入主机之后,它就进入了Netfilter定义的流入路径(Input Path)里。

在这个路径中,IP包要经过路由表路由来决定下一步的去向。而在这次路由之前,Netfilter设置了一个名叫PREROUTING的“检查点”。在Linux内核的实现里,所谓“检查点”实际上就是内核网络协议栈代码里的Hook(比如,在执行路由判断的代码之前,内核会先调用PREROUTING的Hook)。

而在经过路由之后,IP包的去向就分为了两种:

  • 第一种,继续在本机处理;
  • 第二种,被转发到其他目的地。

我们先说一下IP包的第一种去向。这时候,IP包将继续向上层协议栈流动。在它进入传输层之前,Netfilter会设置一个名叫INPUT的“检查点”。到这里,IP包流入路径(Input Path)结束。

接下来,这个IP包通过传输层进入用户空间,交给用户进程处理。而处理完成后,用户进程会通过本机发出返回的IP包。这时候,这个IP包就进入了流出路径(Output Path)。

此时,IP包首先还是会经过主机的路由表进行路由。路由结束后,Netfilter就会设置一个名叫OUTPUT的“检查点”。然后,在OUTPUT之后,再设置一个名叫POSTROUTING“检查点”。

你可能会觉得奇怪,为什么在流出路径结束后,Netfilter会连着设置两个“检查点”呢?

这就要说到在流入路径里,路由判断后的第二种去向了。

在这种情况下,这个IP包不会进入传输层,而是会继续在网络层流动,从而进入到转发路径(Forward Path)。在转发路径中,Netfilter会设置一个名叫FORWARD的“检查点”。

而在FORWARD“检查点”完成后,IP包就会来到流出路径。而转发的IP包由于目的地已经确定,它就不会再经过路由,也自然不会经过OUTPUT,而是会直接来到POSTROUTING“检查点”。

所以说,POSTROUTING的作用,其实就是上述两条路径,最终汇聚在一起的“最终检查点”。

需要注意的是,在有网桥参与的情况下,上述Netfilter设置“检查点”的流程,实际上也会出现在链路层(二层),并且会跟我在上面讲述的网络层(三层)的流程有交互。

这些链路层的“检查点”对应的操作界面叫作ebtables。所以,准确地说,数据包在Linux Netfilter子系统里完整的流动过程,其实应该如下所示(这是一幅来自Netfilter官方的原理图,建议你点击图片以查看大图):

可以看到,我前面为你讲述的,正是上图中绿色部分,也就是网络层的iptables链的工作流程。

另外,你应该还能看到,每一个白色的“检查点”上,还有一个绿色的“标签”,比如:raw、nat、filter等等。

在iptables里,这些标签叫作:表。比如,同样是OUTPUT这个“检查点”,filter Output和nat Output在iptables里的语法和参数,就完全不一样,实现的功能也完全不同。

所以说,iptables表的作用,就是在某个具体的“检查点”(比如Output)上,按顺序执行几个不同的检查动作(比如,先执行nat,再执行filter)。

在理解了iptables的工作原理之后,我们再回到NetworkPolicy上来。这时候,前面由网络插件设置的、负责“拦截”进入Pod的请求的三条iptables规则,就很容易读懂了:

iptables -A FORWARD -d $podIP -m physdev --physdev-is-bridged -j KUBE-POD-SPECIFIC-FW-CHAIN
iptables -A FORWARD -d $podIP -j KUBE-POD-SPECIFIC-FW-CHAIN
...

其中,第一条FORWARD链“拦截”的是一种特殊情况:它对应的是同一台宿主机上容器之间经过CNI网桥进行通信的流入数据包。其中,--physdev-is-bridged的意思就是,这个FORWARD链匹配的是,通过本机上的网桥设备,发往目的地址是podIP的IP包。

当然,如果是像Calico这样的非网桥模式的CNI插件,就不存在这个情况了。

kube-router其实是一个简化版的Calico,它也使用BGP来维护路由信息,但是使用CNI bridge插件负责跟Kubernetes进行交互。

第二条FORWARD链“拦截”的则是最普遍的情况,即:容器跨主通信。这时候,流入容器的数据包都是经过路由转发(FORWARD检查点)来的。

不难看到,这些规则最后都跳转(即:-j)到了名叫KUBE-POD-SPECIFIC-FW-CHAIN的规则上。它正是网络插件为NetworkPolicy设置的第二组规则。

而这个KUBE-POD-SPECIFIC-FW-CHAIN的作用,就是做出“允许”或者“拒绝”的判断。这部分功能的实现,可以简单描述为下面这样的iptables规则:

iptables -A KUBE-POD-SPECIFIC-FW-CHAIN -j KUBE-NWPLCY-CHAIN
iptables -A KUBE-POD-SPECIFIC-FW-CHAIN -j REJECT --reject-with icmp-port-unreachable

可以看到,首先在第一条规则里,我们会把IP包转交给前面定义的KUBE-NWPLCY-CHAIN规则去进行匹配。按照我们之前的讲述,如果匹配成功,那么IP包就会被“允许通过”。

而如果匹配失败,IP包就会来到第二条规则上。可以看到,它是一条REJECT规则。通过这条规则,不满足NetworkPolicy定义的请求就会被拒绝掉,从而实现了对该容器的“隔离”。

以上,就是CNI网络插件实现NetworkPolicy的基本方法了。当然,对于不同的插件来说,上述实现过程可能有不同的手段,但根本原理是不变的。

总结

在本篇文章中,我主要和你分享了Kubernetes对Pod进行“隔离”的手段,即:NetworkPolicy。

可以看到,NetworkPolicy实际上只是宿主机上的一系列iptables规则。这跟传统IaaS里面的安全组(Security Group)其实是非常类似的。

而基于上述讲述,你就会发现这样一个事实:

Kubernetes的网络模型以及大多数容器网络实现,其实既不会保证容器之间二层网络的互通,也不会实现容器之间的二层网络隔离。这跟IaaS项目管理虚拟机的方式,是完全不同的。

所以说,Kubernetes从底层的设计和实现上,更倾向于假设你已经有了一套完整的物理基础设施。然后,Kubernetes负责在此基础上提供一种“弱多租户”(soft multi-tenancy)的能力。

并且,基于上述思路,Kubernetes将来也不大可能把Namespace变成一个具有实质意义的隔离机制,或者把它映射成为“子网”或者“租户”。毕竟你可以看到,NetworkPolicy对象的描述能力,要比基于Namespace的划分丰富得多。

这也是为什么,到目前为止,Kubernetes项目在云计算生态里的定位,其实是基础设施与PaaS之间的中间层。这是非常符合“容器”这个本质上就是进程的抽象粒度的。

当然,随着Kubernetes社区以及CNCF生态的不断发展,Kubernetes项目也已经开始逐步下探,“吃”掉了基础设施领域的很多“蛋糕”。这也正是容器生态继续发展的一个必然方向。

思考题

请你编写这样一个NetworkPolicy:它使得指定的Namespace(比如my-namespace)里的所有Pod,都不能接收任何Ingress请求。然后,请你说说,这样的NetworkPolicy有什么实际的作用?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

37-找到容器不容易:Service、DNS与服务发现

你好,我是张磊。今天我和你分享的主题是:找到容器不容易之Service、DNS与服务发现。

在前面的文章中,我们已经多次使用到了Service这个Kubernetes里重要的服务对象。而Kubernetes之所以需要Service,一方面是因为Pod的IP不是固定的,另一方面则是因为一组Pod实例之间总会有负载均衡的需求。

一个最典型的Service定义,如下所示:

apiVersion: v1
kind: Service
metadata:
  name: hostnames
spec:
  selector:
    app: hostnames
  ports:
  - name: default
    protocol: TCP
    port: 80
    targetPort: 9376

这个Service的例子,相信你不会陌生。其中,我使用了selector字段来声明这个Service只代理携带了app=hostnames标签的Pod。并且,这个Service的80端口,代理的是Pod的9376端口。

然后,我们的应用的Deployment,如下所示:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: hostnames
spec:
  selector:
    matchLabels:
      app: hostnames
  replicas: 3
  template:
    metadata:
      labels:
        app: hostnames
    spec:
      containers:
      - name: hostnames
        image: k8s.gcr.io/serve_hostname
        ports:
        - containerPort: 9376
          protocol: TCP

这个应用的作用,就是每次访问9376端口时,返回它自己的hostname。

而被selector选中的Pod,就称为Service的Endpoints,你可以使用kubectl get ep命令看到它们,如下所示:

$ kubectl get endpoints hostnames
NAME        ENDPOINTS
hostnames   10.244.0.5:9376,10.244.0.6:9376,10.244.0.7:9376

需要注意的是,只有处于Running状态,且readinessProbe检查通过的Pod,才会出现在Service的Endpoints列表里。并且,当某一个Pod出现问题时,Kubernetes会自动把它从Service里摘除掉。

而此时,通过该Service的VIP地址10.0.1.175,你就可以访问到它所代理的Pod了:

$ kubectl get svc hostnames
NAME        TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
hostnames   ClusterIP   10.0.1.175   <none>        80/TCP    5s

$ curl 10.0.1.175:80
hostnames-0uton

$ curl 10.0.1.175:80
hostnames-yp2kp

$ curl 10.0.1.175:80
hostnames-bvc05

这个VIP地址是Kubernetes自动为Service分配的。而像上面这样,通过三次连续不断地访问Service的VIP地址和代理端口80,它就为我们依次返回了三个Pod的hostname。这也正印证了Service提供的是Round Robin方式的负载均衡。对于这种方式,我们称为:ClusterIP模式的Service。

你可能一直比较好奇,Kubernetes里的Service究竟是如何工作的呢?

实际上,Service是由kube-proxy组件,加上iptables来共同实现的。

举个例子,对于我们前面创建的名叫hostnames的Service来说,一旦它被提交给Kubernetes,那么kube-proxy就可以通过Service的Informer感知到这样一个Service对象的添加。而作为对这个事件的响应,它就会在宿主机上创建这样一条iptables规则(你可以通过iptables-save看到它),如下所示:

-A KUBE-SERVICES -d 10.0.1.175/32 -p tcp -m comment --comment "default/hostnames: cluster IP" -m tcp --dport 80 -j KUBE-SVC-NWV5X2332I4OT4T3

可以看到,这条iptables规则的含义是:凡是目的地址是10.0.1.175、目的端口是80的IP包,都应该跳转到另外一条名叫KUBE-SVC-NWV5X2332I4OT4T3的iptables链进行处理。

而我们前面已经看到,10.0.1.175正是这个Service的VIP。所以这一条规则,就为这个Service设置了一个固定的入口地址。并且,由于10.0.1.175只是一条iptables规则上的配置,并没有真正的网络设备,所以你ping这个地址,是不会有任何响应的。

那么,我们即将跳转到的KUBE-SVC-NWV5X2332I4OT4T3规则,又有什么作用呢?

实际上,它是一组规则的集合,如下所示:

-A KUBE-SVC-NWV5X2332I4OT4T3 -m comment --comment "default/hostnames:" -m statistic --mode random --probability 0.33332999982 -j KUBE-SEP-WNBA2IHDGP2BOBGZ
-A KUBE-SVC-NWV5X2332I4OT4T3 -m comment --comment "default/hostnames:" -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-X3P2623AGDH6CDF3
-A KUBE-SVC-NWV5X2332I4OT4T3 -m comment --comment "default/hostnames:" -j KUBE-SEP-57KPRZ3JQVENLNBR

可以看到,这一组规则,实际上是一组随机模式(–mode random)的iptables链。

而随机转发的目的地,分别是KUBE-SEP-WNBA2IHDGP2BOBGZ、KUBE-SEP-X3P2623AGDH6CDF3和KUBE-SEP-57KPRZ3JQVENLNBR。

而这三条链指向的最终目的地,其实就是这个Service代理的三个Pod。所以这一组规则,就是Service实现负载均衡的位置。

需要注意的是,iptables规则的匹配是从上到下逐条进行的,所以为了保证上述三条规则每条被选中的概率都相同,我们应该将它们的probability字段的值分别设置为1/3(0.333…)、1/2和1。

这么设置的原理很简单:第一条规则被选中的概率就是1/3;而如果第一条规则没有被选中,那么这时候就只剩下两条规则了,所以第二条规则的probability就必须设置为1/2;类似地,最后一条就必须设置为1。

你可以想一下,如果把这三条规则的probability字段的值都设置成1/3,最终每条规则被选中的概率会变成多少。

通过查看上述三条链的明细,我们就很容易理解Service进行转发的具体原理了,如下所示:

-A KUBE-SEP-57KPRZ3JQVENLNBR -s 10.244.3.6/32 -m comment --comment "default/hostnames:" -j MARK --set-xmark 0x00004000/0x00004000
-A KUBE-SEP-57KPRZ3JQVENLNBR -p tcp -m comment --comment "default/hostnames:" -m tcp -j DNAT --to-destination 10.244.3.6:9376

-A KUBE-SEP-WNBA2IHDGP2BOBGZ -s 10.244.1.7/32 -m comment --comment "default/hostnames:" -j MARK --set-xmark 0x00004000/0x00004000
-A KUBE-SEP-WNBA2IHDGP2BOBGZ -p tcp -m comment --comment "default/hostnames:" -m tcp -j DNAT --to-destination 10.244.1.7:9376

-A KUBE-SEP-X3P2623AGDH6CDF3 -s 10.244.2.3/32 -m comment --comment "default/hostnames:" -j MARK --set-xmark 0x00004000/0x00004000
-A KUBE-SEP-X3P2623AGDH6CDF3 -p tcp -m comment --comment "default/hostnames:" -m tcp -j DNAT --to-destination 10.244.2.3:9376

可以看到,这三条链,其实是三条DNAT规则。但在DNAT规则之前,iptables对流入的IP包还设置了一个“标志”(–set-xmark)。这个“标志”的作用,我会在下一篇文章再为你讲解。

而DNAT规则的作用,就是在PREROUTING检查点之前,也就是在路由之前,将流入IP包的目的地址和端口,改成–to-destination所指定的新的目的地址和端口。可以看到,这个目的地址和端口,正是被代理Pod的IP地址和端口。

这样,访问Service VIP的IP包经过上述iptables处理之后,就已经变成了访问具体某一个后端Pod的IP包了。不难理解,这些Endpoints对应的iptables规则,正是kube-proxy通过监听Pod的变化事件,在宿主机上生成并维护的。

以上,就是Service最基本的工作原理。

此外,你可能已经听说过,Kubernetes的kube-proxy还支持一种叫作IPVS的模式。这又是怎么一回事儿呢?

其实,通过上面的讲解,你可以看到,kube-proxy通过iptables处理Service的过程,其实需要在宿主机上设置相当多的iptables规则。而且,kube-proxy还需要在控制循环里不断地刷新这些规则来确保它们始终是正确的。

不难想到,当你的宿主机上有大量Pod的时候,成百上千条iptables规则不断地被刷新,会大量占用该宿主机的CPU资源,甚至会让宿主机“卡”在这个过程中。所以说,一直以来,基于iptables的Service实现,都是制约Kubernetes项目承载更多量级的Pod的主要障碍。

而IPVS模式的Service,就是解决这个问题的一个行之有效的方法。

IPVS模式的工作原理,其实跟iptables模式类似。当我们创建了前面的Service之后,kube-proxy首先会在宿主机上创建一个虚拟网卡(叫作:kube-ipvs0),并为它分配Service VIP作为IP地址,如下所示:

# ip addr
  ...
  73:kube-ipvs0:<BROADCAST,NOARP>  mtu 1500 qdisc noop state DOWN qlen 1000
  link/ether  1a:ce:f5:5f:c1:4d brd ff:ff:ff:ff:ff:ff
  inet 10.0.1.175/32  scope global kube-ipvs0
  valid_lft forever  preferred_lft forever

而接下来,kube-proxy就会通过Linux的IPVS模块,为这个IP地址设置三个IPVS虚拟主机,并设置这三个虚拟主机之间使用轮询模式(rr)来作为负载均衡策略。我们可以通过ipvsadm查看到这个设置,如下所示:

# ipvsadm -ln
 IP Virtual Server version 1.2.1 (size=4096)
  Prot LocalAddress:Port Scheduler Flags
    ->  RemoteAddress:Port           Forward  Weight ActiveConn InActConn    
  TCP  10.102.128.4:80 rr
    ->  10.244.3.6:9376    Masq    1       0          0        
    ->  10.244.1.7:9376    Masq    1       0          0
    ->  10.244.2.3:9376    Masq    1       0          0

可以看到,这三个IPVS虚拟主机的IP地址和端口,对应的正是三个被代理的Pod。

这时候,任何发往10.102.128.4:80的请求,就都会被IPVS模块转发到某一个后端Pod上了。

而相比于iptables,IPVS在内核中的实现其实也是基于Netfilter的NAT模式,所以在转发这一层上,理论上IPVS并没有显著的性能提升。但是,IPVS并不需要在宿主机上为每个Pod设置iptables规则,而是把对这些“规则”的处理放到了内核态,从而极大地降低了维护这些规则的代价。这也正印证了我在前面提到过的,“将重要操作放入内核态”是提高性能的重要手段。

备注:这里你可以再回顾下第33篇文章《深入解析容器跨主机网络》中的相关内容。

不过需要注意的是,IPVS模块只负责上述的负载均衡和代理功能。而一个完整的Service流程正常工作所需要的包过滤、SNAT等操作,还是要靠iptables来实现。只不过,这些辅助性的iptables规则数量有限,也不会随着Pod数量的增加而增加。

所以,在大规模集群里,我非常建议你为kube-proxy设置–proxy-mode=ipvs来开启这个功能。它为Kubernetes集群规模带来的提升,还是非常巨大的。

此外,我在前面的文章中还介绍过Service与DNS的关系。

在Kubernetes中,Service和Pod都会被分配对应的DNS A记录(从域名解析IP的记录)。

对于ClusterIP模式的Service来说(比如我们上面的例子),它的A记录的格式是:..svc.cluster.local。当你访问这条A记录的时候,它解析到的就是该Service的VIP地址。

而对于指定了clusterIP=None的Headless Service来说,它的A记录的格式也是:..svc.cluster.local。但是,当你访问这条A记录的时候,它返回的是所有被代理的Pod的IP地址的集合。当然,如果你的客户端没办法解析这个集合的话,它可能会只会拿到第一个Pod的IP地址。

此外,对于ClusterIP模式的Service来说,它代理的Pod被自动分配的A记录的格式是:..pod.cluster.local。这条记录指向Pod的IP地址。

而对Headless Service来说,它代理的Pod被自动分配的A记录的格式是:...svc.cluster.local。这条记录也指向Pod的IP地址。

但如果你为Pod指定了Headless Service,并且Pod本身声明了hostname和subdomain字段,那么这时候Pod的A记录就会变成:<pod的hostname>...svc.cluster.local,比如:

apiVersion: v1
kind: Service
metadata:
  name: default-subdomain
spec:
  selector:
    name: busybox
  clusterIP: None
  ports:
  - name: foo
    port: 1234
    targetPort: 1234
---
apiVersion: v1
kind: Pod
metadata:
  name: busybox1
  labels:
    name: busybox
spec:
  hostname: busybox-1
  subdomain: default-subdomain
  containers:
  - image: busybox
    command:
      - sleep
      - "3600"
    name: busybox

在上面这个Service和Pod被创建之后,你就可以通过busybox-1.default-subdomain.default.svc.cluster.local解析到这个Pod的IP地址了。

需要注意的是,在Kubernetes里,/etc/hosts文件是单独挂载的,这也是为什么kubelet能够对hostname进行修改并且Pod重建后依然有效的原因。这跟Docker的Init层是一个原理。

总结

在这篇文章里,我为你详细讲解了Service的工作原理。实际上,Service机制,以及Kubernetes里的DNS插件,都是在帮助你解决同样一个问题,即:如何找到我的某一个容器?

这个问题在平台级项目中,往往就被称作服务发现,即:当我的一个服务(Pod)的IP地址是不固定的且没办法提前获知时,我该如何通过一个固定的方式访问到这个Pod呢?

而我在这里讲解的、ClusterIP模式的Service为你提供的,就是一个Pod的稳定的IP地址,即VIP。并且,这里Pod和Service的关系是可以通过Label确定的。

而Headless Service为你提供的,则是一个Pod的稳定的DNS名字,并且,这个名字是可以通过Pod名字和Service名字拼接出来的。

在实际的场景里,你应该根据自己的具体需求进行合理选择。

思考题

请问,Kubernetes的Service的负载均衡策略,在iptables和ipvs模式下,都有哪几种?具体工作模式是怎样的?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

38-从外界连通Service与Service调试“三板斧”

你好,我是张磊。今天我和你分享的主题是:从外界连通Service与Service调试“三板斧”。

在上一篇文章中,我为你介绍了Service机制的工作原理。通过这些讲解,你应该能够明白这样一个事实:Service的访问信息在Kubernetes集群之外,其实是无效的。

这其实也容易理解:所谓Service的访问入口,其实就是每台宿主机上由kube-proxy生成的iptables规则,以及kube-dns生成的DNS记录。而一旦离开了这个集群,这些信息对用户来说,也就自然没有作用了。

所以,在使用Kubernetes的Service时,一个必须要面对和解决的问题就是:如何从外部(Kubernetes集群之外),访问到Kubernetes里创建的Service?

这里最常用的一种方式就是:NodePort。我来为你举个例子。

apiVersion: v1
kind: Service
metadata:
  name: my-nginx
  labels:
    run: my-nginx
spec:
  type: NodePort
  ports:
  - nodePort: 8080
    targetPort: 80
    protocol: TCP
    name: http
  - nodePort: 443
    protocol: TCP
    name: https
  selector:
    run: my-nginx

在这个Service的定义里,我们声明它的类型是,type=NodePort。然后,我在ports字段里声明了Service的8080端口代理Pod的80端口,Service的443端口代理Pod的443端口。

当然,如果你不显式地声明nodePort字段,Kubernetes就会为你分配随机的可用端口来设置代理。这个端口的范围默认是30000-32767,你可以通过kube-apiserver的–service-node-port-range参数来修改它。

那么这时候,要访问这个Service,你只需要访问:

<任何一台宿主机的IP地址>:8080

就可以访问到某一个被代理的Pod的80端口了。

而在理解了我在上一篇文章中讲解的Service的工作原理之后,NodePort模式也就非常容易理解了。显然,kube-proxy要做的,就是在每台宿主机上生成这样一条iptables规则:

-A KUBE-NODEPORTS -p tcp -m comment --comment "default/my-nginx: nodePort" -m tcp --dport 8080 -j KUBE-SVC-67RL4FN6JRUPOJYM

而我在上一篇文章中已经讲到,KUBE-SVC-67RL4FN6JRUPOJYM其实就是一组随机模式的iptables规则。所以接下来的流程,就跟ClusterIP模式完全一样了。

需要注意的是,在NodePort方式下,Kubernetes会在IP包离开宿主机发往目的Pod时,对这个IP包做一次SNAT操作,如下所示:

-A KUBE-POSTROUTING -m comment --comment "kubernetes service traffic requiring SNAT" -m mark --mark 0x4000/0x4000 -j MASQUERADE

可以看到,这条规则设置在POSTROUTING检查点,也就是说,它给即将离开这台主机的IP包,进行了一次SNAT操作,将这个IP包的源地址替换成了这台宿主机上的CNI网桥地址,或者宿主机本身的IP地址(如果CNI网桥不存在的话)。

当然,这个SNAT操作只需要对Service转发出来的IP包进行(否则普通的IP包就被影响了)。而iptables做这个判断的依据,就是查看该IP包是否有一个“0x4000”的“标志”。你应该还记得,这个标志正是在IP包被执行DNAT操作之前被打上去的。

可是,为什么一定要对流出的包做SNAT操作呢?

这里的原理其实很简单,如下所示:

           client
             \ ^
              \ \
               v \
   node 1 <--- node 2
    | ^   SNAT
    | |   --->
    v |
 endpoint

当一个外部的client通过node 2的地址访问一个Service的时候,node 2上的负载均衡规则,就可能把这个IP包转发给一个在node 1上的Pod。这里没有任何问题。

而当node 1上的这个Pod处理完请求之后,它就会按照这个IP包的源地址发出回复。

可是,如果没有做SNAT操作的话,这时候,被转发来的IP包的源地址就是client的IP地址。所以此时,Pod就会直接将回复发client。对于client来说,它的请求明明发给了node 2,收到的回复却来自node 1,这个client很可能会报错。

所以,在上图中,当IP包离开node 2之后,它的源IP地址就会被SNAT改成node 2的CNI网桥地址或者node 2自己的地址。这样,Pod在处理完成之后就会先回复给node 2(而不是client),然后再由node 2发送给client。

当然,这也就意味着这个Pod只知道该IP包来自于node 2,而不是外部的client。对于Pod需要明确知道所有请求来源的场景来说,这是不可以的。

所以这时候,你就可以将Service的spec.externalTrafficPolicy字段设置为local,这就保证了所有Pod通过Service收到请求之后,一定可以看到真正的、外部client的源地址。

而这个机制的实现原理也非常简单:这时候,一台宿主机上的iptables规则,会设置为只将IP包转发给运行在这台宿主机上的Pod。所以这时候,Pod就可以直接使用源地址将回复包发出,不需要事先进行SNAT了。这个流程,如下所示:

       client
       ^ /   \
      / /     \
     / v       X
   node 1     node 2
    ^ |
    | |
    | v
 endpoint

当然,这也就意味着如果在一台宿主机上,没有任何一个被代理的Pod存在,比如上图中的node 2,那么你使用node 2的IP地址访问这个Service,就是无效的。此时,你的请求会直接被DROP掉。

从外部访问Service的第二种方式,适用于公有云上的Kubernetes服务。这时候,你可以指定一个LoadBalancer类型的Service,如下所示:

---
kind: Service
apiVersion: v1
metadata:
  name: example-service
spec:
  ports:
  - port: 8765
    targetPort: 9376
  selector:
    app: example
  type: LoadBalancer

在公有云提供的Kubernetes服务里,都使用了一个叫作CloudProvider的转接层,来跟公有云本身的 API进行对接。所以,在上述LoadBalancer类型的Service被提交后,Kubernetes就会调用CloudProvider在公有云上为你创建一个负载均衡服务,并且把被代理的Pod的IP地址配置给负载均衡服务做后端。

而第三种方式,是Kubernetes在1.7之后支持的一个新特性,叫作ExternalName。举个例子:

kind: Service
apiVersion: v1
metadata:
  name: my-service
spec:
  type: ExternalName
  externalName: my.database.example.com

在上述Service的YAML文件中,我指定了一个externalName=my.database.example.com的字段。而且你应该会注意到,这个YAML文件里不需要指定selector。

这时候,当你通过Service的DNS名字访问它的时候,比如访问:my-service.default.svc.cluster.local。那么,Kubernetes为你返回的就是my.database.example.com。所以说,ExternalName类型的Service,其实是在kube-dns里为你添加了一条CNAME记录。这时,访问my-service.default.svc.cluster.local就和访问my.database.example.com这个域名是一个效果了。

此外,Kubernetes的Service还允许你为Service分配公有IP地址,比如下面这个例子:

kind: Service
apiVersion: v1
metadata:
  name: my-service
spec:
  selector:
    app: MyApp
  ports:
  - name: http
    protocol: TCP
    port: 80
    targetPort: 9376
  externalIPs:
  - 80.11.12.10

在上述Service中,我为它指定的externalIPs=80.11.12.10,那么此时,你就可以通过访问80.11.12.10:80访问到被代理的Pod了。不过,在这里Kubernetes要求externalIPs必须是至少能够路由到一个Kubernetes的节点。你可以想一想这是为什么。

实际上,在理解了Kubernetes Service机制的工作原理之后,很多与Service相关的问题,其实都可以通过分析Service在宿主机上对应的iptables规则(或者IPVS配置)得到解决。

比如,当你的Service没办法通过DNS访问到的时候。你就需要区分到底是Service本身的配置问题,还是集群的DNS出了问题。一个行之有效的方法,就是检查Kubernetes自己的Master节点的Service DNS是否正常:

# 在一个Pod里执行
$ nslookup kubernetes.default
Server:    10.0.0.10
Address 1: 10.0.0.10 kube-dns.kube-system.svc.cluster.local

Name:      kubernetes.default
Address 1: 10.0.0.1 kubernetes.default.svc.cluster.local

如果上面访问kubernetes.default返回的值都有问题,那你就需要检查kube-dns的运行状态和日志了。否则的话,你应该去检查自己的 Service 定义是不是有问题。

而如果你的Service没办法通过ClusterIP访问到的时候,你首先应该检查的是这个Service是否有Endpoints:

$ kubectl get endpoints hostnames
NAME        ENDPOINTS
hostnames   10.244.0.5:9376,10.244.0.6:9376,10.244.0.7:9376

需要注意的是,如果你的Pod的readniessProbe没通过,它也不会出现在Endpoints列表里。

而如果Endpoints正常,那么你就需要确认kube-proxy是否在正确运行。在我们通过kubeadm部署的集群里,你应该看到kube-proxy输出的日志如下所示:

I1027 22:14:53.995134    5063 server.go:200] Running in resource-only container "/kube-proxy"
I1027 22:14:53.998163    5063 server.go:247] Using iptables Proxier.
I1027 22:14:53.999055    5063 server.go:255] Tearing down userspace rules. Errors here are acceptable.
I1027 22:14:54.038140    5063 proxier.go:352] Setting endpoints for "kube-system/kube-dns:dns-tcp" to [10.244.1.3:53]
I1027 22:14:54.038164    5063 proxier.go:352] Setting endpoints for "kube-system/kube-dns:dns" to [10.244.1.3:53]
I1027 22:14:54.038209    5063 proxier.go:352] Setting endpoints for "default/kubernetes:https" to [10.240.0.2:443]
I1027 22:14:54.038238    5063 proxier.go:429] Not syncing iptables until Services and Endpoints have been received from master
I1027 22:14:54.040048    5063 proxier.go:294] Adding new service "default/kubernetes:https" at 10.0.0.1:443/TCP
I1027 22:14:54.040154    5063 proxier.go:294] Adding new service "kube-system/kube-dns:dns" at 10.0.0.10:53/UDP
I1027 22:14:54.040223    5063 proxier.go:294] Adding new service "kube-system/kube-dns:dns-tcp" at 10.0.0.10:53/TCP

如果kube-proxy一切正常,你就应该仔细查看宿主机上的iptables了。而一个iptables模式的Service对应的规则,我在上一篇以及这一篇文章里已经全部介绍到了,它们包括:

  1. KUBE-SERVICES或者KUBE-NODEPORTS规则对应的Service的入口链,这个规则应该与VIP和Service端口一一对应;

  2. KUBE-SEP-(hash)规则对应的DNAT链,这些规则应该与Endpoints一一对应;

  3. KUBE-SVC-(hash)规则对应的负载均衡链,这些规则的数目应该与 Endpoints 数目一致;

  4. 如果是NodePort模式的话,还有POSTROUTING处的SNAT链。

通过查看这些链的数量、转发目的地址、端口、过滤条件等信息,你就能很容易发现一些异常的蛛丝马迹。

当然,还有一种典型问题,就是Pod没办法通过Service访问到自己。这往往就是因为kubelet的hairpin-mode没有被正确设置。关于Hairpin的原理我在前面已经介绍过,这里就不再赘述了。你只需要确保将kubelet的hairpin-mode设置为hairpin-veth或者promiscuous-bridge即可。

这里,你可以再回顾下第34篇文章《Kubernetes网络模型与CNI网络插件》中的相关内容。

其中,在hairpin-veth模式下,你应该能看到CNI 网桥对应的各个VETH设备,都将Hairpin模式设置为了1,如下所示:

$ for d in /sys/devices/virtual/net/cni0/brif/veth*/hairpin_mode; do echo "$d = $(cat $d)"; done
/sys/devices/virtual/net/cni0/brif/veth4bfbfe74/hairpin_mode = 1
/sys/devices/virtual/net/cni0/brif/vethfc2a18c5/hairpin_mode = 1

而如果是promiscuous-bridge模式的话,你应该看到CNI网桥的混杂模式(PROMISC)被开启,如下所示:

$ ifconfig cni0 |grep PROMISC
UP BROADCAST RUNNING PROMISC MULTICAST  MTU:1460  Metric:1

总结

在本篇文章中,我为你详细讲解了从外部访问Service的三种方式(NodePort、LoadBalancer 和 External Name)和具体的工作原理。然后,我还为你讲述了当Service出现故障的时候,如何根据它的工作原理,按照一定的思路去定位问题的可行之道。

通过上述讲解不难看出,所谓Service,其实就是Kubernetes为Pod分配的、固定的、基于iptables(或者IPVS)的访问入口。而这些访问入口代理的Pod信息,则来自于Etcd,由kube-proxy通过控制循环来维护。

并且,你可以看到,Kubernetes里面的Service和DNS机制,也都不具备强多租户能力。比如,在多租户情况下,每个租户应该拥有一套独立的Service规则(Service只应该看到和代理同一个租户下的Pod)。再比如DNS,在多租户情况下,每个租户应该拥有自己的kube-dns(kube-dns只应该为同一个租户下的Service和Pod创建DNS Entry)。

当然,在Kubernetes中,kube-proxy和kube-dns其实也是普通的插件而已。你完全可以根据自己的需求,实现符合自己预期的Service。

思考题

为什么Kubernetes要求externalIPs必须是至少能够路由到一个Kubernetes的节点?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

39-谈谈Service与Ingress

你好,我是张磊。今天我和你分享的主题是:谈谈Service与Ingress。

在上一篇文章中,我为你详细讲解了将Service暴露给外界的三种方法。其中有一个叫作LoadBalancer类型的Service,它会为你在Cloud Provider(比如:Google Cloud或者OpenStack)里创建一个与该Service对应的负载均衡服务。

但是,相信你也应该能感受到,由于每个 Service 都要有一个负载均衡服务,所以这个做法实际上既浪费成本又高。作为用户,我其实更希望看到Kubernetes为我内置一个全局的负载均衡器。然后,通过我访问的URL,把请求转发给不同的后端Service。

这种全局的、为了代理不同后端Service而设置的负载均衡服务,就是Kubernetes里的Ingress服务。

所以,Ingress的功能其实很容易理解:所谓Ingress,就是Service的“Service”。

举个例子,假如我现在有这样一个站点:https://cafe.example.com。其中,https://cafe.example.com/coffee,对应的是“咖啡点餐系统”。而,https://cafe.example.com/tea,对应的则是“茶水点餐系统”。这两个系统,分别由名叫coffee和tea这样两个Deployment来提供服务。

那么现在,我如何能使用Kubernetes的Ingress来创建一个统一的负载均衡器,从而实现当用户访问不同的域名时,能够访问到不同的Deployment呢?

上述功能,在Kubernetes里就需要通过Ingress对象来描述,如下所示:

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: cafe-ingress
spec:
  tls:
  - hosts:
    - cafe.example.com
    secretName: cafe-secret
  rules:
  - host: cafe.example.com
    http:
      paths:
      - path: /tea
        backend:
          serviceName: tea-svc
          servicePort: 80
      - path: /coffee
        backend:
          serviceName: coffee-svc
          servicePort: 80

在上面这个名叫cafe-ingress.yaml文件中,最值得我们关注的,是rules字段。在Kubernetes里,这个字段叫作:IngressRule

IngressRule的Key,就叫做:host。它必须是一个标准的域名格式(Fully Qualified Domain Name)的字符串,而不能是IP地址。

备注:Fully Qualified Domain Name的具体格式,可以参考RFC 3986标准。

而host字段定义的值,就是这个Ingress的入口。这也就意味着,当用户访问cafe.example.com的时候,实际上访问到的是这个Ingress对象。这样,Kubernetes就能使用IngressRule来对你的请求进行下一步转发。

而接下来IngressRule规则的定义,则依赖于path字段。你可以简单地理解为,这里的每一个path都对应一个后端Service。所以在我们的例子里,我定义了两个path,它们分别对应coffee和tea这两个Deployment的Service(即:coffee-svc和tea-svc)。

通过上面的讲解,不难看到,所谓Ingress对象,其实就是Kubernetes项目对“反向代理”的一种抽象。

一个Ingress对象的主要内容,实际上就是一个“反向代理”服务(比如:Nginx)的配置文件的描述。而这个代理服务对应的转发规则,就是IngressRule。

这就是为什么在每条IngressRule里,需要有一个host字段来作为这条IngressRule的入口,然后还需要有一系列path字段来声明具体的转发策略。这其实跟Nginx、HAproxy等项目的配置文件的写法是一致的。

而有了Ingress这样一个统一的抽象,Kubernetes的用户就无需关心Ingress的具体细节了。

在实际的使用中,你只需要从社区里选择一个具体的Ingress Controller,把它部署在Kubernetes集群里即可。

然后,这个Ingress Controller会根据你定义的Ingress对象,提供对应的代理能力。目前,业界常用的各种反向代理项目,比如Nginx、HAProxy、Envoy、Traefik等,都已经为Kubernetes专门维护了对应的Ingress Controller。

接下来,我就以最常用的Nginx Ingress Controller为例,在我们前面用kubeadm部署的Bare-metal环境中,和你实践一下Ingress机制的使用过程。

部署Nginx Ingress Controller的方法非常简单,如下所示:

$ kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/master/deploy/mandatory.yaml

其中,在mandatory.yaml这个文件里,正是Nginx官方为你维护的Ingress Controller的定义。我们来看一下它的内容:

kind: ConfigMap
apiVersion: v1
metadata:
  name: nginx-configuration
  namespace: ingress-nginx
  labels:
    app.kubernetes.io/name: ingress-nginx
    app.kubernetes.io/part-of: ingress-nginx
---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: nginx-ingress-controller
  namespace: ingress-nginx
  labels:
    app.kubernetes.io/name: ingress-nginx
    app.kubernetes.io/part-of: ingress-nginx
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: ingress-nginx
      app.kubernetes.io/part-of: ingress-nginx
  template:
    metadata:
      labels:
        app.kubernetes.io/name: ingress-nginx
        app.kubernetes.io/part-of: ingress-nginx
      annotations:
        ...
    spec:
      serviceAccountName: nginx-ingress-serviceaccount
      containers:
        - name: nginx-ingress-controller
          image: quay.io/kubernetes-ingress-controller/nginx-ingress-controller:0.20.0
          args:
            - /nginx-ingress-controller
            - --configmap=$(POD_NAMESPACE)/nginx-configuration
            - --publish-service=$(POD_NAMESPACE)/ingress-nginx
            - --annotations-prefix=nginx.ingress.kubernetes.io
          securityContext:
            capabilities:
              drop:
                - ALL
              add:
                - NET_BIND_SERVICE
            # www-data -> 33
            runAsUser: 33
          env:
            - name: POD_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
            - name: POD_NAMESPACE
            - name: http
              valueFrom:
                fieldRef:
                  fieldPath: metadata.namespace
          ports:
            - name: http
              containerPort: 80
            - name: https
              containerPort: 443

可以看到,在上述YAML文件中,我们定义了一个使用nginx-ingress-controller镜像的Pod。需要注意的是,这个Pod的启动命令需要使用该Pod所在的Namespace作为参数。而这个信息,当然是通过Downward API拿到的,即:Pod的env字段里的定义(env.valueFrom.fieldRef.fieldPath)。

而这个Pod本身,就是一个监听Ingress对象以及它所代理的后端Service变化的控制器。

当一个新的Ingress对象由用户创建后,nginx-ingress-controller就会根据Ingress对象里定义的内容,生成一份对应的Nginx配置文件(/etc/nginx/nginx.conf),并使用这个配置文件启动一个 Nginx 服务。

而一旦Ingress对象被更新,nginx-ingress-controller就会更新这个配置文件。需要注意的是,如果这里只是被代理的 Service 对象被更新,nginx-ingress-controller所管理的 Nginx 服务是不需要重新加载(reload)的。这当然是因为nginx-ingress-controller通过Nginx Lua方案实现了Nginx Upstream的动态配置。

此外,nginx-ingress-controller还允许你通过Kubernetes的ConfigMap对象来对上述 Nginx 配置文件进行定制。这个ConfigMap的名字,需要以参数的方式传递给nginx-ingress-controller。而你在这个 ConfigMap 里添加的字段,将会被合并到最后生成的 Nginx 配置文件当中。

可以看到,一个Nginx Ingress Controller为你提供的服务,其实是一个可以根据Ingress对象和被代理后端 Service 的变化,来自动进行更新的Nginx负载均衡器。

当然,为了让用户能够用到这个Nginx,我们就需要创建一个Service来把Nginx Ingress Controller管理的 Nginx 服务暴露出去,如下所示:

$ kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/master/deploy/provider/baremetal/service-nodeport.yaml

由于我们使用的是Bare-metal环境,所以service-nodeport.yaml文件里的内容,就是一个NodePort类型的Service,如下所示:

apiVersion: v1
kind: Service
metadata:
  name: ingress-nginx
  namespace: ingress-nginx
  labels:
    app.kubernetes.io/name: ingress-nginx
    app.kubernetes.io/part-of: ingress-nginx
spec:
  type: NodePort
  ports:
    - name: http
      port: 80
      targetPort: 80
      protocol: TCP
    - name: https
      port: 443
      targetPort: 443
      protocol: TCP
  selector:
    app.kubernetes.io/name: ingress-nginx
    app.kubernetes.io/part-of: ingress-nginx

可以看到,这个Service的唯一工作,就是将所有携带ingress-nginx标签的Pod的80和433端口暴露出去。

而如果你是公有云上的环境,你需要创建的就是LoadBalancer类型的Service了。

上述操作完成后,你一定要记录下这个Service的访问入口,即:宿主机的地址和NodePort的端口,如下所示:

$ kubectl get svc -n ingress-nginx
NAME            TYPE       CLUSTER-IP     EXTERNAL-IP   PORT(S)                      AGE
ingress-nginx   NodePort   10.105.72.96   <none>        80:30044/TCP,443:31453/TCP   3h

为了后面方便使用,我会把上述访问入口设置为环境变量:

$ IC_IP=10.168.0.2 # 任意一台宿主机的地址
$ IC_HTTPS_PORT=31453 # NodePort端口

在Ingress Controller和它所需要的Service部署完成后,我们就可以使用它了。

备注:这个“咖啡厅”Ingress的所有示例文件,都在这里

首先,我们要在集群里部署我们的应用Pod和它们对应的Service,如下所示:

$ kubectl create -f cafe.yaml

然后,我们需要创建Ingress所需的SSL证书(tls.crt)和密钥(tls.key),这些信息都是通过Secret对象定义好的,如下所示:

$ kubectl create -f cafe-secret.yaml

这一步完成后,我们就可以创建在本篇文章一开始定义的Ingress对象了,如下所示:

$ kubectl create -f cafe-ingress.yaml

这时候,我们就可以查看一下这个Ingress对象的信息,如下所示:

$ kubectl get ingress
NAME           HOSTS              ADDRESS   PORTS     AGE
cafe-ingress   cafe.example.com             80, 443   2h

$ kubectl describe ingress cafe-ingress
Name:             cafe-ingress
Namespace:        default
Address:          
Default backend:  default-http-backend:80 (<none>)
TLS:
  cafe-secret terminates cafe.example.com
Rules:
  Host              Path  Backends
  ----              ----  --------
  cafe.example.com  
                    /tea      tea-svc:80 (<none>)
                    /coffee   coffee-svc:80 (<none>)
Annotations:
Events:
  Type    Reason  Age   From                      Message
  ----    ------  ----  ----                      -------
  Normal  CREATE  4m    nginx-ingress-controller  Ingress default/cafe-ingress

可以看到,这个Ingress对象最核心的部分,正是Rules字段。其中,我们定义的Host是cafe.example.com,它有两条转发规则(Path),分别转发给tea-svc和coffee-svc。

当然,在Ingress的YAML文件里,你还可以定义多个Host,比如restaurant.example.commovie.example.com等等,来为更多的域名提供负载均衡服务。

接下来,我们就可以通过访问这个Ingress的地址和端口,访问到我们前面部署的应用了,比如,当我们访问https://cafe.example.com:443/coffee时,应该是coffee这个Deployment负责响应我的请求。我们可以来尝试一下:

$ curl --resolve cafe.example.com:$IC_HTTPS_PORT:$IC_IP https://cafe.example.com:$IC_HTTPS_PORT/coffee --insecureServer address: 10.244.1.56:80
Server name: coffee-7dbb5795f6-vglbv
Date: 03/Nov/2018:03:55:32 +0000
URI: /coffee
Request ID: e487e672673195c573147134167cf898

我们可以看到,访问这个URL 得到的返回信息是:Server name: coffee-7dbb5795f6-vglbv。这正是 coffee 这个 Deployment 的名字。

而当我访问https://cafe.example.com:433/tea的时候,则应该是tea这个Deployment负责响应我的请求(Server name: tea-7d57856c44-lwbnp),如下所示:

$ curl --resolve cafe.example.com:$IC_HTTPS_PORT:$IC_IP https://cafe.example.com:$IC_HTTPS_PORT/tea --insecure
Server address: 10.244.1.58:80
Server name: tea-7d57856c44-lwbnp
Date: 03/Nov/2018:03:55:52 +0000
URI: /tea
Request ID: 32191f7ea07cb6bb44a1f43b8299415c

可以看到,Nginx Ingress Controller为我们创建的Nginx负载均衡器,已经成功地将请求转发给了对应的后端Service。

以上,就是Kubernetes里Ingress的设计思想和使用方法了。

不过,你可能会有一个疑问,如果我的请求没有匹配到任何一条IngressRule,那么会发生什么呢?

首先,既然Nginx Ingress Controller是用Nginx实现的,那么它当然会为你返回一个 Nginx 的404页面。

不过,Ingress Controller也允许你通过Pod启动命令里的–default-backend-service参数,设置一条默认规则,比如:–default-backend-service=nginx-default-backend。

这样,任何匹配失败的请求,就都会被转发到这个名叫nginx-default-backend的Service。所以,你就可以通过部署一个专门的Pod,来为用户返回自定义的404页面了。

总结

在这篇文章里,我为你详细讲解了Ingress这个概念在Kubernetes里到底是怎么一回事儿。正如我在文章里所描述的,Ingress实际上就是Kubernetes对“反向代理”的抽象。

目前,Ingress只能工作在七层,而Service只能工作在四层。所以当你想要在Kubernetes里为应用进行TLS配置等HTTP相关的操作时,都必须通过Ingress来进行。

当然,正如同很多负载均衡项目可以同时提供七层和四层代理一样,将来Ingress的进化中,也会加入四层代理的能力。这样,一个比较完善的“反向代理”机制就比较成熟了。

而Kubernetes提出Ingress概念的原因其实也非常容易理解,有了Ingress这个抽象,用户就可以根据自己的需求来自由选择Ingress Controller。比如,如果你的应用对代理服务的中断非常敏感,那么你就应该考虑选择类似于Traefik这样支持“热加载”的Ingress Controller实现。

更重要的是,一旦你对社区里现有的Ingress方案感到不满意,或者你已经有了自己的负载均衡方案时,你只需要做很少的编程工作,就可以实现一个自己的Ingress Controller。

在实际的生产环境中,Ingress带来的灵活度和自由度,对于使用容器的用户来说,其实是非常有意义的。要知道,当年在Cloud Foundry项目里,不知道有多少人为了给Gorouter组件配置一个TLS而伤透了脑筋。

思考题

如果我的需求是,当访问www.mysite.comforums.mysite.com时,分别访问到不同的Service(比如:site-svc和forums-svc)。那么,这个Ingress该如何定义呢?请你描述出YAML文件中的rules字段。

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

40-Kubernetes的资源模型与资源管理

你好,我是张磊。今天我和你分享的主题是:Kubernetes的资源模型与资源管理。

作为一个容器集群编排与管理项目,Kubernetes为用户提供的基础设施能力,不仅包括了我在前面为你讲述的应用定义和描述的部分,还包括了对应用的资源管理和调度的处理。那么,从今天这篇文章开始,我就来为你详细讲解一下后面这部分内容。

而作为Kubernetes的资源管理与调度部分的基础,我们要从它的资源模型开始说起。

我在前面的文章中已经提到过,在Kubernetes里,Pod是最小的原子调度单位。这也就意味着,所有跟调度和资源管理相关的属性都应该是属于Pod对象的字段。而这其中最重要的部分,就是Pod的CPU和内存配置,如下所示:

apiVersion: v1
kind: Pod
metadata:
  name: frontend
spec:
  containers:
  - name: db
    image: mysql
    env:
    - name: MYSQL_ROOT_PASSWORD
      value: "password"
    resources:
      requests:
        memory: "64Mi"
        cpu: "250m"
      limits:
        memory: "128Mi"
        cpu: "500m"
  - name: wp
    image: wordpress
    resources:
      requests:
        memory: "64Mi"
        cpu: "250m"
      limits:
        memory: "128Mi"
        cpu: "500m"

备注:关于哪些属性属于Pod对象,而哪些属性属于Container,你可以在回顾一下第14篇文章《深入解析Pod对象(一):基本概念》中的相关内容。

在Kubernetes中,像CPU这样的资源被称作“可压缩资源”(compressible resources)。它的典型特点是,当可压缩资源不足时,Pod只会“饥饿”,但不会退出。

而像内存这样的资源,则被称作“不可压缩资源(incompressible resources)。当不可压缩资源不足时,Pod就会因为OOM(Out-Of-Memory)被内核杀掉。

而由于Pod可以由多个Container组成,所以CPU和内存资源的限额,是要配置在每个Container的定义上的。这样,Pod整体的资源配置,就由这些Container的配置值累加得到。

其中,Kubernetes里为CPU设置的单位是“CPU的个数”。比如,cpu=1指的就是,这个Pod的CPU限额是1个CPU。当然,具体“1个CPU”在宿主机上如何解释,是1个CPU核心,还是1个vCPU,还是1个CPU的超线程(Hyperthread),完全取决于宿主机的CPU实现方式。Kubernetes只负责保证Pod能够使用到“1个CPU”的计算能力。

此外,Kubernetes允许你将CPU限额设置为分数,比如在我们的例子里,CPU limits的值就是500m。所谓500m,指的就是500 millicpu,也就是0.5个CPU的意思。这样,这个Pod就会被分配到1个CPU一半的计算能力。

当然,你也可以直接把这个配置写成cpu=0.5。但在实际使用时,我还是推荐你使用500m的写法,毕竟这才是Kubernetes内部通用的CPU表示方式。

而对于内存资源来说,它的单位自然就是bytes。Kubernetes支持你使用Ei、Pi、Ti、Gi、Mi、Ki(或者E、P、T、G、M、K)的方式来作为bytes的值。比如,在我们的例子里,Memory requests的值就是64MiB (2的26次方bytes) 。这里要注意区分MiB(mebibyte)和MB(megabyte)的区别。

备注:1Mi=1024*1024;1M=1000*1000

此外,不难看到,Kubernetes里Pod的CPU和内存资源,实际上还要分为limits和requests两种情况,如下所示:

spec.containers[].resources.limits.cpu
spec.containers[].resources.limits.memory
spec.containers[].resources.requests.cpu
spec.containers[].resources.requests.memory

这两者的区别其实非常简单:在调度的时候,kube-scheduler只会按照requests的值进行计算。而在真正设置Cgroups限制的时候,kubelet则会按照limits的值来进行设置。

更确切地说,当你指定了requests.cpu=250m之后,相当于将Cgroups的cpu.shares的值设置为(250/1000)*1024。而当你没有设置requests.cpu的时候,cpu.shares默认则是1024。这样,Kubernetes就通过cpu.shares完成了对CPU时间的按比例分配。

而如果你指定了limits.cpu=500m之后,则相当于将Cgroups的cpu.cfs_quota_us的值设置为(500/1000)*100ms,而cpu.cfs_period_us的值始终是100ms。这样,Kubernetes就为你设置了这个容器只能用到CPU的50%。

而对于内存来说,当你指定了limits.memory=128Mi之后,相当于将Cgroups的memory.limit_in_bytes设置为128 * 1024 * 1024。而需要注意的是,在调度的时候,调度器只会使用requests.memory=64Mi来进行判断。

Kubernetes这种对CPU和内存资源限额的设计,实际上参考了Borg论文中对“动态资源边界”的定义,既:容器化作业在提交时所设置的资源边界,并不一定是调度系统所必须严格遵守的,这是因为在实际场景中,大多数作业使用到的资源其实远小于它所请求的资源限额。

基于这种假设,Borg在作业被提交后,会主动减小它的资源限额配置,以便容纳更多的作业、提升资源利用率。而当作业资源使用量增加到一定阈值时,Borg会通过“快速恢复”过程,还原作业原始的资源限额,防止出现异常情况。

而Kubernetes的requests+limits的做法,其实就是上述思路的一个简化版:用户在提交Pod时,可以声明一个相对较小的requests值供调度器使用,而Kubernetes真正设置给容器Cgroups的,则是相对较大的limits值。不难看到,这跟Borg的思路相通的。

在理解了Kubernetes资源模型的设计之后,我再来和你谈谈Kubernetes里的QoS模型。在Kubernetes中,不同的requests和limits的设置方式,其实会将这个Pod划分到不同的QoS级别当中。

当Pod里的每一个Container都同时设置了requests和limits,并且requests和limits值相等的时候,这个Pod就属于Guaranteed类别,如下所示:

apiVersion: v1
kind: Pod
metadata:
  name: qos-demo
  namespace: qos-example
spec:
  containers:
  - name: qos-demo-ctr
    image: nginx
    resources:
      limits:
        memory: "200Mi"
        cpu: "700m"
      requests:
        memory: "200Mi"
        cpu: "700m"

当这个Pod创建之后,它的qosClass字段就会被Kubernetes自动设置为Guaranteed。需要注意的是,当Pod仅设置了limits没有设置requests的时候,Kubernetes会自动为它设置与limits相同的requests值,所以,这也属于Guaranteed情况。

而当Pod不满足Guaranteed的条件,但至少有一个Container设置了requests。那么这个Pod就会被划分到Burstable类别。比如下面这个例子:

apiVersion: v1
kind: Pod
metadata:
  name: qos-demo-2
  namespace: qos-example
spec:
  containers:
  - name: qos-demo-2-ctr
    image: nginx
    resources:
      limits
        memory: "200Mi"
      requests:
        memory: "100Mi"

而如果一个Pod既没有设置requests,也没有设置limits,那么它的QoS类别就是BestEffort。比如下面这个例子:

apiVersion: v1
kind: Pod
metadata:
  name: qos-demo-3
  namespace: qos-example
spec:
  containers:
  - name: qos-demo-3-ctr
    image: nginx

那么,Kubernetes为Pod设置这样三种QoS类别,具体有什么作用呢?

实际上,QoS划分的主要应用场景,是当宿主机资源紧张的时候,kubelet对Pod进行Eviction(即资源回收)时需要用到的。

具体地说,当Kubernetes所管理的宿主机上不可压缩资源短缺时,就有可能触发Eviction。比如,可用内存(memory.available)、可用的宿主机磁盘空间(nodefs.available),以及容器运行时镜像存储空间(imagefs.available)等等。

目前,Kubernetes为你设置的Eviction的默认阈值如下所示:

memory.available<100Mi
nodefs.available<10%
nodefs.inodesFree<5%
imagefs.available<15%

当然,上述各个触发条件在kubelet里都是可配置的。比如下面这个例子:

kubelet --eviction-hard=imagefs.available<10%,memory.available<500Mi,nodefs.available<5%,nodefs.inodesFree<5% --eviction-soft=imagefs.available<30%,nodefs.available<10% --eviction-soft-grace-period=imagefs.available=2m,nodefs.available=2m --eviction-max-pod-grace-period=600

在这个配置中,你可以看到Eviction在Kubernetes里其实分为Soft和Hard两种模式

其中,Soft Eviction允许你为Eviction过程设置一段“优雅时间”,比如上面例子里的imagefs.available=2m,就意味着当imagefs不足的阈值达到2分钟之后,kubelet才会开始Eviction的过程。

而Hard Eviction模式下,Eviction过程就会在阈值达到之后立刻开始。

Kubernetes计算Eviction阈值的数据来源,主要依赖于从Cgroups读取到的值,以及使用cAdvisor监控到的数据。

当宿主机的Eviction阈值达到后,就会进入MemoryPressure或者DiskPressure状态,从而避免新的Pod被调度到这台宿主机上。

而当Eviction发生的时候,kubelet具体会挑选哪些Pod进行删除操作,就需要参考这些Pod的QoS类别了。

  • 首当其冲的,自然是BestEffort类别的Pod。
  • 其次,是属于Burstable类别、并且发生“饥饿”的资源使用量已经超出了requests的Pod。
  • 最后,才是Guaranteed类别。并且,Kubernetes会保证只有当Guaranteed类别的Pod的资源使用量超过了其limits的限制,或者宿主机本身正处于Memory Pressure状态时,Guaranteed的Pod才可能被选中进行Eviction操作。

当然,对于同QoS类别的Pod来说,Kubernetes还会根据Pod的优先级来进行进一步地排序和选择。

在理解了Kubernetes里的QoS类别的设计之后,我再来为你讲解一下Kubernetes里一个非常有用的特性:cpuset的设置。

我们知道,在使用容器的时候,你可以通过设置cpuset把容器绑定到某个CPU的核上,而不是像cpushare那样共享CPU的计算能力。

这种情况下,由于操作系统在CPU之间进行上下文切换的次数大大减少,容器里应用的性能会得到大幅提升。事实上,cpuset方式,是生产环境里部署在线应用类型的Pod时,非常常用的一种方式。

可是,这样的需求在Kubernetes里又该如何实现呢?

其实非常简单。

  • 首先,你的Pod必须是Guaranteed的QoS类型;
  • 然后,你只需要将Pod的CPU资源的requests和limits设置为同一个相等的整数值即可。

比如下面这个例子:

spec:
  containers:
  - name: nginx
    image: nginx
    resources:
      limits:
        memory: "200Mi"
        cpu: "2"
      requests:
        memory: "200Mi"
        cpu: "2"

这时候,该Pod就会被绑定在2个独占的CPU核上。当然,具体是哪两个CPU核,是由kubelet为你分配的。

以上,就是Kubernetes的资源模型和QoS类别相关的主要内容。

总结

在本篇文章中,我先为你详细讲解了Kubernetes里对资源的定义方式和资源模型的设计。然后,我为你讲述了Kubernetes里对Pod进行Eviction的具体策略和实践方式。

正是基于上述讲述,在实际的使用中,我强烈建议你将DaemonSet的Pod都设置为Guaranteed的QoS类型。否则,一旦DaemonSet的Pod被回收,它又会立即在原宿主机上被重建出来,这就使得前面资源回收的动作,完全没有意义了。

思考题

为什么宿主机进入MemoryPressure或者DiskPressure状态后,新的Pod就不会被调度到这台宿主机上呢?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

41-十字路口上的Kubernetes默认调度器

你好,我是张磊。今天我和你分享的主题是:十字路口上的Kubernetes默认调度器。

在上一篇文章中,我主要为你介绍了Kubernetes里关于资源模型和资源管理的设计方法。而在今天这篇文章中,我就来为你介绍一下Kubernetes的默认调度器(default scheduler)。

在Kubernetes项目中,默认调度器的主要职责,就是为一个新创建出来的Pod,寻找一个最合适的节点(Node)。

而这里“最合适”的含义,包括两层:

  1. 从集群所有的节点中,根据调度算法挑选出所有可以运行该Pod的节点;

  2. 从第一步的结果中,再根据调度算法挑选一个最符合条件的节点作为最终结果。

所以在具体的调度流程中,默认调度器会首先调用一组叫作Predicate的调度算法,来检查每个Node。然后,再调用一组叫作Priority的调度算法,来给上一步得到的结果里的每个Node打分。最终的调度结果,就是得分最高的那个Node。

而我在前面的文章中曾经介绍过,调度器对一个Pod调度成功,实际上就是将它的spec.nodeName字段填上调度结果的节点名字。

备注:这里你可以再回顾下第14篇文章《深入解析Pod对象(一):基本概念》中的相关内容。

在Kubernetes中,上述调度机制的工作原理,可以用如下所示的一幅示意图来表示。

可以看到,Kubernetes的调度器的核心,实际上就是两个相互独立的控制循环。

其中,第一个控制循环,我们可以称之为Informer Path。它的主要目的,是启动一系列Informer,用来监听(Watch)Etcd中Pod、Node、Service等与调度相关的API对象的变化。比如,当一个待调度Pod(即:它的nodeName字段是空的)被创建出来之后,调度器就会通过Pod Informer的Handler,将这个待调度Pod添加进调度队列。

在默认情况下,Kubernetes的调度队列是一个PriorityQueue(优先级队列),并且当某些集群信息发生变化的时候,调度器还会对调度队列里的内容进行一些特殊操作。这里的设计,主要是出于调度优先级和抢占的考虑,我会在后面的文章中再详细介绍这部分内容。

此外,Kubernetes的默认调度器还要负责对调度器缓存(即:scheduler cache)进行更新。事实上,Kubernetes 调度部分进行性能优化的一个最根本原则,就是尽最大可能将集群信息Cache化,以便从根本上提高Predicate和Priority调度算法的执行效率。

第二个控制循环,是调度器负责Pod调度的主循环,我们可以称之为Scheduling Path。

Scheduling Path的主要逻辑,就是不断地从调度队列里出队一个Pod。然后,调用Predicates算法进行“过滤”。这一步“过滤”得到的一组Node,就是所有可以运行这个Pod的宿主机列表。当然,Predicates算法需要的Node信息,都是从Scheduler Cache里直接拿到的,这是调度器保证算法执行效率的主要手段之一。

接下来,调度器就会再调用Priorities算法为上述列表里的Node打分,分数从0到10。得分最高的Node,就会作为这次调度的结果。

调度算法执行完成后,调度器就需要将Pod对象的nodeName字段的值,修改为上述Node的名字。这个步骤在Kubernetes里面被称作Bind。

但是,为了不在关键调度路径里远程访问APIServer,Kubernetes的默认调度器在Bind阶段,只会更新Scheduler Cache里的Pod和Node的信息。这种基于“乐观”假设的API对象更新方式,在Kubernetes里被称作Assume。

Assume之后,调度器才会创建一个Goroutine来异步地向APIServer发起更新Pod的请求,来真正完成 Bind 操作。如果这次异步的Bind过程失败了,其实也没有太大关系,等Scheduler Cache同步之后一切就会恢复正常。

当然,正是由于上述Kubernetes调度器的“乐观”绑定的设计,当一个新的Pod完成调度需要在某个节点上运行起来之前,该节点上的kubelet还会通过一个叫作Admit的操作来再次验证该Pod是否确实能够运行在该节点上。这一步Admit操作,实际上就是把一组叫作GeneralPredicates的、最基本的调度算法,比如:“资源是否可用”“端口是否冲突”等再执行一遍,作为 kubelet 端的二次确认。

备注:关于Kubernetes默认调度器的调度算法,我会在下一篇文章里为你讲解。

除了上述的“Cache化”和“乐观绑定”,Kubernetes默认调度器还有一个重要的设计,那就是“无锁化”。

在Scheduling Path上,调度器会启动多个Goroutine以节点为粒度并发执行Predicates算法,从而提高这一阶段的执行效率。而与之类似的,Priorities算法也会以MapReduce的方式并行计算然后再进行汇总。而在这些所有需要并发的路径上,调度器会避免设置任何全局的竞争资源,从而免去了使用锁进行同步带来的巨大的性能损耗。

所以,在这种思想的指导下,如果你再去查看一下前面的调度器原理图,你就会发现,Kubernetes调度器只有对调度队列和Scheduler Cache进行操作时,才需要加锁。而这两部分操作,都不在Scheduling Path的算法执行路径上。

当然,Kubernetes调度器的上述设计思想,也是在集群规模不断增长的演进过程中逐步实现的。尤其是 “Cache化”,这个变化其实是最近几年Kubernetes调度器性能得以提升的一个关键演化。

不过,随着Kubernetes项目发展到今天,它的默认调度器也已经来到了一个关键的十字路口。事实上,Kubernetes现今发展的主旋律,是整个开源项目的“民主化”。也就是说,Kubernetes下一步发展的方向,是组件的轻量化、接口化和插件化。所以,我们才有了CRI、CNI、CSI、CRD、Aggregated APIServer、Initializer、Device Plugin等各个层级的可扩展能力。可是,默认调度器,却成了Kubernetes项目里最后一个没有对外暴露出良好定义过的、可扩展接口的组件。

当然,这是有一定的历史原因的。在过去几年,Kubernetes发展的重点,都是以功能性需求的实现和完善为核心。在这个过程中,它的很多决策,还是以优先服务公有云的需求为主,而性能和规模则居于相对次要的位置。

而现在,随着Kubernetes项目逐步趋于稳定,越来越多的用户开始把Kubernetes用在规模更大、业务更加复杂的私有集群当中。很多以前的Mesos用户,也开始尝试使用Kubernetes来替代其原有架构。在这些场景下,对默认调度器进行扩展和重新实现,就成了社区对Kubernetes项目最主要的一个诉求。

所以,Kubernetes的默认调度器,是目前这个项目里为数不多的、正在经历大量重构的核心组件之一。这些正在进行的重构的目的,一方面是将默认调度器里大量的“技术债”清理干净;另一方面,就是为默认调度器的可扩展性设计进行铺垫。

而Kubernetes默认调度器的可扩展性设计,可以用如下所示的一幅示意图来描述:

可以看到,默认调度器的可扩展机制,在Kubernetes里面叫作Scheduler Framework。顾名思义,这个设计的主要目的,就是在调度器生命周期的各个关键点上,为用户暴露出可以进行扩展和实现的接口,从而实现由用户自定义调度器的能力。

上图中,每一个绿色的箭头都是一个可以插入自定义逻辑的接口。比如,上面的Queue部分,就意味着你可以在这一部分提供一个自己的调度队列的实现,从而控制每个Pod开始被调度(出队)的时机。

而Predicates部分,则意味着你可以提供自己的过滤算法实现,根据自己的需求,来决定选择哪些机器。

需要注意的是,上述这些可插拔式逻辑,都是标准的Go语言插件机制(Go plugin 机制),也就是说,你需要在编译的时候选择把哪些插件编译进去。

有了上述设计之后,扩展和自定义Kubernetes的默认调度器就变成了一件非常容易实现的事情。这也意味着默认调度器在后面的发展过程中,必然不会在现在的实现上再添加太多的功能,反而还会对现在的实现进行精简,最终成为Scheduler Framework的一个最小实现。而调度领域更多的创新和工程工作,就可以交给整个社区来完成了。这个思路,是完全符合我在前面提到的Kubernetes的“民主化”设计的。

不过,这样的Scheduler Framework也有一个不小的问题,那就是一旦这些插入点的接口设计不合理,就会导致整个生态没办法很好地把这个插件机制使用起来。而与此同时,这些接口本身的变更又是一个费时费力的过程,一旦把控不好,就很可能会把社区推向另一个极端,即:Scheduler Framework没法实际落地,大家只好都再次fork kube-scheduler。

总结

在本篇文章中,我为你详细讲解了Kubernetes里默认调度器的设计与实现,分析了它现在正在经历的重构,以及未来的走向。

不难看到,在 Kubernetes 的整体架构中,kube-scheduler 的责任虽然重大,但其实它却是在社区里最少受到关注的组件之一。这里的原因也很简单,调度这个事情,在不同的公司和团队里的实际需求一定是大相径庭的,上游社区不可能提供一个大而全的方案出来。所以,将默认调度器进一步做轻做薄,并且插件化,才是 kube-scheduler 正确的演进方向。

思考题

请问,Kubernetes默认调度器与Mesos的“两级”调度器,有什么异同呢?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

42-Kubernetes默认调度器调度策略解析

你好,我是张磊。今天我和你分享的主题是:Kubernetes默认调度器调度策略解析。

在上一篇文章中,我主要为你讲解了Kubernetes默认调度器的设计原理和架构。在今天这篇文章中,我们就专注在调度过程中Predicates和Priorities这两个调度策略主要发生作用的阶段。

首先,我们一起看看Predicates。

Predicates在调度过程中的作用,可以理解为Filter,即:它按照调度策略,从当前集群的所有节点中,“过滤”出一系列符合条件的节点。这些节点,都是可以运行待调度Pod的宿主机。

而在Kubernetes中,默认的调度策略有如下四种。

第一种类型,叫作GeneralPredicates。

顾名思义,这一组过滤规则,负责的是最基础的调度策略。比如,PodFitsResources计算的就是宿主机的CPU和内存资源等是否够用。

当然,我在前面已经提到过,PodFitsResources检查的只是 Pod 的 requests 字段。需要注意的是,Kubernetes 的调度器并没有为 GPU 等硬件资源定义具体的资源类型,而是统一用一种名叫 Extended Resource的、Key-Value 格式的扩展字段来描述的。比如下面这个例子:

apiVersion: v1
kind: Pod
metadata:
  name: extended-resource-demo
spec:
  containers:
  - name: extended-resource-demo-ctr
    image: nginx
    resources:
      requests:
        alpha.kubernetes.io/nvidia-gpu: 2
      limits:
        alpha.kubernetes.io/nvidia-gpu: 2

可以看到,我们这个 Pod 通过alpha.kubernetes.io/nvidia-gpu=2这样的定义方式,声明使用了两个 NVIDIA 类型的 GPU。

而在PodFitsResources里面,调度器其实并不知道这个字段 Key 的含义是 GPU,而是直接使用后面的 Value 进行计算。当然,在 Node 的Capacity字段里,你也得相应地加上这台宿主机上 GPU的总数,比如:alpha.kubernetes.io/nvidia-gpu=4。这些流程,我在后面讲解 Device Plugin 的时候会详细介绍。

而PodFitsHost检查的是,宿主机的名字是否跟Pod的spec.nodeName一致。

PodFitsHostPorts检查的是,Pod申请的宿主机端口(spec.nodePort)是不是跟已经被使用的端口有冲突。

PodMatchNodeSelector检查的是,Pod的nodeSelector或者nodeAffinity指定的节点,是否与待考察节点匹配,等等。

可以看到,像上面这样一组GeneralPredicates,正是Kubernetes考察一个Pod能不能运行在一个Node上最基本的过滤条件。所以,GeneralPredicates也会被其他组件(比如kubelet)直接调用。

我在上一篇文章中已经提到过,kubelet在启动Pod前,会执行一个Admit操作来进行二次确认。这里二次确认的规则,就是执行一遍GeneralPredicates。

第二种类型,是与Volume相关的过滤规则。

这一组过滤规则,负责的是跟容器持久化Volume相关的调度策略。

其中,NoDiskConflict检查的条件,是多个Pod声明挂载的持久化Volume是否有冲突。比如,AWS EBS类型的Volume,是不允许被两个Pod同时使用的。所以,当一个名叫A的EBS Volume已经被挂载在了某个节点上时,另一个同样声明使用这个A Volume的Pod,就不能被调度到这个节点上了。

而MaxPDVolumeCountPredicate检查的条件,则是一个节点上某种类型的持久化Volume是不是已经超过了一定数目,如果是的话,那么声明使用该类型持久化Volume的Pod就不能再调度到这个节点了。

而VolumeZonePredicate,则是检查持久化Volume的Zone(高可用域)标签,是否与待考察节点的Zone标签相匹配。

此外,这里还有一个叫作VolumeBindingPredicate的规则。它负责检查的,是该Pod对应的PV的nodeAffinity字段,是否跟某个节点的标签相匹配。

在前面的第29篇文章《PV、PVC体系是不是多此一举?从本地持久化卷谈起》中,我曾经为你讲解过,Local Persistent Volume(本地持久化卷),必须使用nodeAffinity来跟某个具体的节点绑定。这其实也就意味着,在Predicates阶段,Kubernetes就必须能够根据Pod的Volume属性来进行调度。

此外,如果该Pod的PVC还没有跟具体的PV绑定的话,调度器还要负责检查所有待绑定PV,当有可用的PV存在并且该PV的nodeAffinity与待考察节点一致时,这条规则才会返回“成功”。比如下面这个例子:

apiVersion: v1
kind: PersistentVolume
metadata:
  name: example-local-pv
spec:
  capacity:
    storage: 500Gi
  accessModes:
  - ReadWriteOnce
  persistentVolumeReclaimPolicy: Retain
  storageClassName: local-storage
  local:
    path: /mnt/disks/vol1
  nodeAffinity:
    required:
      nodeSelectorTerms:
      - matchExpressions:
        - key: kubernetes.io/hostname
          operator: In
          values:
          - my-node

可以看到,这个 PV 对应的持久化目录,只会出现在名叫 my-node 的宿主机上。所以,任何一个通过 PVC 使用这个 PV 的 Pod,都必须被调度到 my-node 上才可以正常工作。VolumeBindingPredicate,正是调度器里完成这个决策的位置。

第三种类型,是宿主机相关的过滤规则。

这一组规则,主要考察待调度 Pod 是否满足 Node 本身的某些条件。

比如,PodToleratesNodeTaints,负责检查的就是我们前面经常用到的Node 的“污点”机制。只有当 Pod 的 Toleration 字段与 Node 的 Taint 字段能够匹配的时候,这个 Pod 才能被调度到该节点上。

备注:这里,你也可以再回顾下第21篇文章《容器化守护进程的意义:DaemonSet》中的相关内容。

而NodeMemoryPressurePredicate,检查的是当前节点的内存是不是已经不够充足,如果是的话,那么待调度 Pod 就不能被调度到该节点上。

第四种类型,是 Pod 相关的过滤规则。

这一组规则,跟 GeneralPredicates大多数是重合的。而比较特殊的,是PodAffinityPredicate。这个规则的作用,是检查待调度 Pod 与 Node 上的已有Pod 之间的亲密(affinity)和反亲密(anti-affinity)关系。比如下面这个例子:

apiVersion: v1
kind: Pod
metadata:
  name: with-pod-antiaffinity
spec:
  affinity:
    podAntiAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
      - weight: 100  
        podAffinityTerm:
          labelSelector:
            matchExpressions:
            - key: security
              operator: In
              values:
              - S2
          topologyKey: kubernetes.io/hostname
  containers:
  - name: with-pod-affinity
    image: docker.io/ocpqe/hello-pod

这个例子里的podAntiAffinity规则,就指定了这个 Pod 不希望跟任何携带了 security=S2 标签的 Pod 存在于同一个 Node 上。需要注意的是,PodAffinityPredicate是有作用域的,比如上面这条规则,就仅对携带了Key 是kubernetes.io/hostname标签的 Node 有效。这正是topologyKey这个关键词的作用。

而与podAntiAffinity相反的,就是podAffinity,比如下面这个例子:

apiVersion: v1
kind: Pod
metadata:
  name: with-pod-affinity
spec:
  affinity:
    podAffinity:
      requiredDuringSchedulingIgnoredDuringExecution:
      - labelSelector:
          matchExpressions:
          - key: security
            operator: In
            values:
            - S1
        topologyKey: failure-domain.beta.kubernetes.io/zone
  containers:
  - name: with-pod-affinity
    image: docker.io/ocpqe/hello-pod

这个例子里的 Pod,就只会被调度到已经有携带了 security=S1标签的 Pod 运行的 Node 上。而这条规则的作用域,则是所有携带 Key 是failure-domain.beta.kubernetes.io/zone标签的 Node。

此外,上面这两个例子里的requiredDuringSchedulingIgnoredDuringExecution字段的含义是:这条规则必须在Pod 调度时进行检查(requiredDuringScheduling);但是如果是已经在运行的Pod 发生变化,比如 Label 被修改,造成了该 Pod 不再适合运行在这个 Node 上的时候,Kubernetes 不会进行主动修正(IgnoredDuringExecution)。

上面这四种类型的Predicates,就构成了调度器确定一个 Node 可以运行待调度 Pod 的基本策略。

在具体执行的时候, 当开始调度一个 Pod 时,Kubernetes 调度器会同时启动16个Goroutine,来并发地为集群里的所有Node 计算 Predicates,最后返回可以运行这个 Pod 的宿主机列表。

需要注意的是,在为每个 Node 执行 Predicates 时,调度器会按照固定的顺序来进行检查。这个顺序,是按照 Predicates 本身的含义来确定的。比如,宿主机相关的Predicates 会被放在相对靠前的位置进行检查。要不然的话,在一台资源已经严重不足的宿主机上,上来就开始计算 PodAffinityPredicate,是没有实际意义的。

接下来,我们再来看一下 Priorities。

在 Predicates 阶段完成了节点的“过滤”之后,Priorities 阶段的工作就是为这些节点打分。这里打分的范围是0-10分,得分最高的节点就是最后被 Pod 绑定的最佳节点。

Priorities 里最常用到的一个打分规则,是LeastRequestedPriority。它的计算方法,可以简单地总结为如下所示的公式:

score = (cpu((capacity-sum(requested))10/capacity) + memory((capacity-sum(requested))10/capacity))/2

可以看到,这个算法实际上就是在选择空闲资源(CPU 和 Memory)最多的宿主机。

而与LeastRequestedPriority一起发挥作用的,还有BalancedResourceAllocation。它的计算公式如下所示:

score = 10 - variance(cpuFraction,memoryFraction,volumeFraction)*10

其中,每种资源的 Fraction 的定义是 :Pod 请求的资源/节点上的可用资源。而 variance 算法的作用,则是计算每两种资源 Fraction 之间的“距离”。而最后选择的,则是资源 Fraction 差距最小的节点。

所以说,BalancedResourceAllocation选择的,其实是调度完成后,所有节点里各种资源分配最均衡的那个节点,从而避免一个节点上 CPU 被大量分配、而 Memory 大量剩余的情况。

此外,还有NodeAffinityPriority、TaintTolerationPriority和InterPodAffinityPriority这三种 Priority。顾名思义,它们与前面的PodMatchNodeSelector、PodToleratesNodeTaints和 PodAffinityPredicate这三个 Predicate 的含义和计算方法是类似的。但是作为 Priority,一个 Node 满足上述规则的字段数目越多,它的得分就会越高。

在默认 Priorities 里,还有一个叫作ImageLocalityPriority的策略。它是在 Kubernetes v1.12里新开启的调度规则,即:如果待调度 Pod 需要使用的镜像很大,并且已经存在于某些 Node 上,那么这些Node 的得分就会比较高。

当然,为了避免这个算法引发调度堆叠,调度器在计算得分的时候还会根据镜像的分布进行优化,即:如果大镜像分布的节点数目很少,那么这些节点的权重就会被调低,从而“对冲”掉引起调度堆叠的风险。

以上,就是 Kubernetes 调度器的 Predicates 和 Priorities 里默认调度规则的主要工作原理了。

在实际的执行过程中,调度器里关于集群和 Pod 的信息都已经缓存化,所以这些算法的执行过程还是比较快的。

此外,对于比较复杂的调度算法来说,比如PodAffinityPredicate,它们在计算的时候不只关注待调度 Pod 和待考察 Node,还需要关注整个集群的信息,比如,遍历所有节点,读取它们的 Labels。这时候,Kubernetes 调度器会在为每个待调度 Pod 执行该调度算法之前,先将算法需要的集群信息初步计算一遍,然后缓存起来。这样,在真正执行该算法的时候,调度器只需要读取缓存信息进行计算即可,从而避免了为每个 Node 计算 Predicates 的时候反复获取和计算整个集群的信息。

总结

在本篇文章中,我为你讲述了 Kubernetes 默认调度器里的主要调度算法。

需要注意的是,除了本篇讲述的这些规则,Kubernetes 调度器里其实还有一些默认不会开启的策略。你可以通过为kube-scheduler 指定一个配置文件或者创建一个 ConfigMap ,来配置哪些规则需要开启、哪些规则需要关闭。并且,你可以通过为 Priorities 设置权重,来控制调度器的调度行为。

思考题

请问,如何能够让 Kubernetes 的调度器尽可能地将 Pod 分布在不同机器上,避免“堆叠”呢?请简单描述下你的算法。

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

43-Kubernetes默认调度器的优先级与抢占机制

你好,我是张磊。今天我和你分享的主题是:Kubernetes默认调度器的优先级与抢占机制。

在上一篇文章中,我为你详细讲解了 Kubernetes 默认调度器的主要调度算法的工作原理。在本篇文章中,我再来为你讲解一下 Kubernetes 调度器里的另一个重要机制,即:优先级(Priority )和抢占(Preemption)机制。

首先需要明确的是,优先级和抢占机制,解决的是 Pod 调度失败时该怎么办的问题。

正常情况下,当一个 Pod 调度失败后,它就会被暂时“搁置”起来,直到 Pod 被更新,或者集群状态发生变化,调度器才会对这个 Pod进行重新调度。

但在有时候,我们希望的是这样一个场景。当一个高优先级的 Pod 调度失败后,该 Pod 并不会被“搁置”,而是会“挤走”某个 Node 上的一些低优先级的 Pod 。这样就可以保证这个高优先级 Pod 的调度成功。这个特性,其实也是一直以来就存在于 Borg 以及 Mesos 等项目里的一个基本功能。

而在 Kubernetes 里,优先级和抢占机制是在1.10版本后才逐步可用的。要使用这个机制,你首先需要在 Kubernetes 里提交一个 PriorityClass 的定义,如下所示:

apiVersion: scheduling.k8s.io/v1beta1
kind: PriorityClass
metadata:
  name: high-priority
value: 1000000
globalDefault: false
description: "This priority class should be used for high priority service pods only."

上面这个 YAML 文件,定义的是一个名叫high-priority的 PriorityClass,其中value的值是1000000 (一百万)。

Kubernetes 规定,优先级是一个32 bit的整数,最大值不超过1000000000(10亿,1 billion),并且值越大代表优先级越高。而超出10亿的值,其实是被Kubernetes保留下来分配给系统 Pod使用的。显然,这样做的目的,就是保证系统 Pod 不会被用户抢占掉。

而一旦上述 YAML 文件里的 globalDefault被设置为 true 的话,那就意味着这个 PriorityClass 的值会成为系统的默认值。而如果这个值是 false,就表示我们只希望声明使用该 PriorityClass 的 Pod 拥有值为1000000的优先级,而对于没有声明 PriorityClass 的 Pod来说,它们的优先级就是0。

在创建了 PriorityClass 对象之后,Pod 就可以声明使用它了,如下所示:

apiVersion: v1
kind: Pod
metadata:
  name: nginx
  labels:
    env: test
spec:
  containers:
  - name: nginx
    image: nginx
    imagePullPolicy: IfNotPresent
  priorityClassName: high-priority

可以看到,这个 Pod 通过priorityClassName字段,声明了要使用名叫high-priority的PriorityClass。当这个 Pod 被提交给 Kubernetes 之后,Kubernetes 的PriorityAdmissionController 就会自动将这个 Pod 的spec.priority字段设置为1000000。

而我在前面的文章中曾为你介绍过,调度器里维护着一个调度队列。所以,当 Pod 拥有了优先级之后,高优先级的 Pod 就可能会比低优先级的 Pod 提前出队,从而尽早完成调度过程。这个过程,就是“优先级”这个概念在 Kubernetes 里的主要体现。

备注:这里,你可以再回顾一下第41篇文章《十字路口上的Kubernetes默认调度器》中的相关内容。

而当一个高优先级的 Pod 调度失败的时候,调度器的抢占能力就会被触发。这时,调度器就会试图从当前集群里寻找一个节点,使得当这个节点上的一个或者多个低优先级 Pod 被删除后,待调度的高优先级 Pod 就可以被调度到这个节点上。这个过程,就是“抢占”这个概念在 Kubernetes 里的主要体现。

为了方便叙述,我接下来会把待调度的高优先级 Pod 称为“抢占者”(Preemptor)。

当上述抢占过程发生时,抢占者并不会立刻被调度到被抢占的 Node 上。事实上,调度器只会将抢占者的spec.nominatedNodeName字段,设置为被抢占的 Node 的名字。然后,抢占者会重新进入下一个调度周期,然后在新的调度周期里来决定是不是要运行在被抢占的节点上。这当然也就意味着,即使在下一个调度周期,调度器也不会保证抢占者一定会运行在被抢占的节点上。

这样设计的一个重要原因是,调度器只会通过标准的 DELETE API 来删除被抢占的 Pod,所以,这些 Pod 必然是有一定的“优雅退出”时间(默认是30s)的。而在这段时间里,其他的节点也是有可能变成可调度的,或者直接有新的节点被添加到这个集群中来。所以,鉴于优雅退出期间,集群的可调度性可能会发生的变化,把抢占者交给下一个调度周期再处理,是一个非常合理的选择。

而在抢占者等待被调度的过程中,如果有其他更高优先级的 Pod 也要抢占同一个节点,那么调度器就会清空原抢占者的spec.nominatedNodeName字段,从而允许更高优先级的抢占者执行抢占,并且,这也就使得原抢占者本身,也有机会去重新抢占其他节点。这些,都是设置nominatedNodeName字段的主要目的。

那么,Kubernetes 调度器里的抢占机制,又是如何设计的呢?

接下来,我就为你详细讲述一下这其中的原理。

我在前面已经提到过,抢占发生的原因,一定是一个高优先级的 Pod 调度失败。这一次,我们还是称这个 Pod 为“抢占者”,称被抢占的 Pod 为“牺牲者”(victims)。

而Kubernetes 调度器实现抢占算法的一个最重要的设计,就是在调度队列的实现里,使用了两个不同的队列。

第一个队列,叫作activeQ。凡是在 activeQ 里的 Pod,都是下一个调度周期需要调度的对象。所以,当你在 Kubernetes 集群里新创建一个 Pod 的时候,调度器会将这个 Pod 入队到 activeQ 里面。而我在前面提到过的、调度器不断从队列里出队(Pop)一个 Pod 进行调度,实际上都是从 activeQ 里出队的。

第二个队列,叫作unschedulableQ,专门用来存放调度失败的 Pod。

而这里的一个关键点就在于,当一个unschedulableQ里的 Pod 被更新之后,调度器会自动把这个 Pod 移动到activeQ里,从而给这些调度失败的 Pod “重新做人”的机会。

现在,回到我们的抢占者调度失败这个时间点上来。

调度失败之后,抢占者就会被放进unschedulableQ里面。

然后,这次失败事件就会触发调度器为抢占者寻找牺牲者的流程

第一步,调度器会检查这次失败事件的原因,来确认抢占是不是可以帮助抢占者找到一个新节点。这是因为有很多 Predicates的失败是不能通过抢占来解决的。比如,PodFitsHost算法(负责的是,检查Pod 的 nodeSelector与 Node 的名字是否匹配),这种情况下,除非 Node 的名字发生变化,否则你即使删除再多的 Pod,抢占者也不可能调度成功。

第二步,如果确定抢占可以发生,那么调度器就会把自己缓存的所有节点信息复制一份,然后使用这个副本来模拟抢占过程。

这里的抢占过程很容易理解。调度器会检查缓存副本里的每一个节点,然后从该节点上最低优先级的Pod开始,逐一“删除”这些Pod。而每删除一个低优先级Pod,调度器都会检查一下抢占者是否能够运行在该 Node 上。一旦可以运行,调度器就记录下这个 Node 的名字和被删除 Pod 的列表,这就是一次抢占过程的结果了。

当遍历完所有的节点之后,调度器会在上述模拟产生的所有抢占结果里做一个选择,找出最佳结果。而这一步的判断原则,就是尽量减少抢占对整个系统的影响。比如,需要抢占的 Pod 越少越好,需要抢占的 Pod 的优先级越低越好,等等。

在得到了最佳的抢占结果之后,这个结果里的 Node,就是即将被抢占的 Node;被删除的 Pod 列表,就是牺牲者。所以接下来,调度器就可以真正开始抢占的操作了,这个过程,可以分为三步。

第一步,调度器会检查牺牲者列表,清理这些 Pod 所携带的nominatedNodeName字段。

第二步,调度器会把抢占者的nominatedNodeName,设置为被抢占的Node 的名字。

第三步,调度器会开启一个 Goroutine,同步地删除牺牲者。

而第二步对抢占者 Pod 的更新操作,就会触发到我前面提到的“重新做人”的流程,从而让抢占者在下一个调度周期重新进入调度流程。

所以接下来,调度器就会通过正常的调度流程把抢占者调度成功。这也是为什么,我前面会说调度器并不保证抢占的结果:在这个正常的调度流程里,是一切皆有可能的。

不过,对于任意一个待调度 Pod来说,因为有上述抢占者的存在,它的调度过程,其实是有一些特殊情况需要特殊处理的。

具体来说,在为某一对 Pod 和 Node 执行 Predicates 算法的时候,如果待检查的 Node 是一个即将被抢占的节点,即:调度队列里有nominatedNodeName字段值是该 Node 名字的 Pod 存在(可以称之为:“潜在的抢占者”)。那么,调度器就会对这个 Node ,将同样的 Predicates 算法运行两遍。

第一遍, 调度器会假设上述“潜在的抢占者”已经运行在这个节点上,然后执行 Predicates 算法;

第二遍, 调度器会正常执行Predicates算法,即:不考虑任何“潜在的抢占者”。

而只有这两遍 Predicates 算法都能通过时,这个 Pod 和 Node 才会被认为是可以绑定(bind)的。

不难想到,这里需要执行第一遍Predicates算法的原因,是由于InterPodAntiAffinity 规则的存在。

由于InterPodAntiAffinity规则关心待考察节点上所有 Pod之间的互斥关系,所以我们在执行调度算法时必须考虑,如果抢占者已经存在于待考察 Node 上时,待调度 Pod 还能不能调度成功。

当然,这也就意味着,我们在这一步只需要考虑那些优先级等于或者大于待调度 Pod 的抢占者。毕竟对于其他较低优先级 Pod 来说,待调度 Pod 总是可以通过抢占运行在待考察 Node 上。

而我们需要执行第二遍Predicates 算法的原因,则是因为“潜在的抢占者”最后不一定会运行在待考察的 Node 上。关于这一点,我在前面已经讲解过了:Kubernetes调度器并不保证抢占者一定会运行在当初选定的被抢占的 Node 上。

以上,就是 Kubernetes 默认调度器里优先级和抢占机制的实现原理了。

总结

在本篇文章中,我为你详细讲述了 Kubernetes 里关于 Pod 的优先级和抢占机制的设计与实现。

这个特性在v1.11之后已经是Beta了,意味着比较稳定了。所以,我建议你在Kubernetes集群中开启这两个特性,以便实现更高的资源使用率。

思考题

当整个集群发生可能会影响调度结果的变化(比如,添加或者更新 Node,添加和更新 PV、Service等)时,调度器会执行一个被称为MoveAllToActiveQueue的操作,把所调度失败的 Pod 从 unscheduelableQ 移动到activeQ 里面。请问这是为什么?

一个相似的问题是,当一个已经调度成功的 Pod 被更新时,调度器则会将unschedulableQ 里所有跟这个 Pod 有 Affinity/Anti-affinity 关系的 Pod,移动到 activeQ 里面。请问这又是为什么呢?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

44-KubernetesGPU管理与DevicePlugin机制

你好,我是张磊。今天我和你分享的主题是:Kubernetes GPU管理与Device Plugin机制。

2016年,随着 AlphaGo 的走红和TensorFlow 项目的异军突起,一场名为 AI 的技术革命迅速从学术界蔓延到了工业界,所谓的 AI 元年,就此拉开帷幕。

当然,机器学习或者说人工智能,并不是什么新鲜的概念。而这次热潮的背后,云计算服务的普及与成熟,以及算力的巨大提升,其实正是将人工智能从象牙塔带到工业界的一个重要推手。

而与之相对应的,从2016年开始,Kubernetes 社区就不断收到来自不同渠道的大量诉求,希望能够在 Kubernetes 集群上运行 TensorFlow 等机器学习框架所创建的训练(Training)和服务(Serving)任务。而这些诉求中,除了前面我为你讲解过的 Job、Operator 等离线作业管理需要用到的编排概念之外,还有一个亟待实现的功能,就是对 GPU 等硬件加速设备管理的支持。

不过, 正如同 TensorFlow 之于 Google 的战略意义一样,GPU 支持对于 Kubernetes 项目来说,其实也有着超过技术本身的考虑。所以,尽管在硬件加速器这个领域里,Kubernetes 上游有着不少来自 NVIDIA 和 Intel 等芯片厂商的工程师,但这个特性本身,却从一开始就是以 Google Cloud 的需求为主导来推进的。

而对于云的用户来说,在 GPU 的支持上,他们最基本的诉求其实非常简单:我只要在 Pod 的 YAML 里面,声明某容器需要的 GPU 个数,那么Kubernetes 为我创建的容器里就应该出现对应的 GPU 设备,以及它对应的驱动目录。

以 NVIDIA 的 GPU 设备为例,上面的需求就意味着当用户的容器被创建之后,这个容器里必须出现如下两部分设备和目录:

  1. GPU 设备,比如 /dev/nvidia0;

  2. GPU 驱动目录,比如/usr/local/nvidia/*。

其中,GPU 设备路径,正是该容器启动时的 Devices 参数;而驱动目录,则是该容器启动时的 Volume 参数。所以,在 Kubernetes 的GPU 支持的实现里,kubelet 实际上就是将上述两部分内容,设置在了创建该容器的 CRI (Container Runtime Interface)参数里面。这样,等到该容器启动之后,对应的容器里就会出现 GPU 设备和驱动的路径了。

不过,Kubernetes 在 Pod 的 API 对象里,并没有为 GPU 专门设置一个资源类型字段,而是使用了一种叫作 Extended Resource(ER)的特殊字段来负责传递 GPU 的信息。比如下面这个例子:

apiVersion: v1
kind: Pod
metadata:
  name: cuda-vector-add
spec:
  restartPolicy: OnFailure
  containers:
    - name: cuda-vector-add
      image: "k8s.gcr.io/cuda-vector-add:v0.1"
      resources:
        limits:
          nvidia.com/gpu: 1

可以看到,在上述 Pod 的 limits 字段里,这个资源的名称是nvidia.com/gpu,它的值是1。也就是说,这个 Pod 声明了自己要使用一个 NVIDIA 类型的GPU。

而在 kube-scheduler 里面,它其实并不关心这个字段的具体含义,只会在计算的时候,一律将调度器里保存的该类型资源的可用量,直接减去 Pod 声明的数值即可。所以说,Extended Resource,其实是 Kubernetes 为用户设置的一种对自定义资源的支持。

当然,为了能够让调度器知道这个自定义类型的资源在每台宿主机上的可用量,宿主机节点本身,就必须能够向 API Server 汇报该类型资源的可用数量。在 Kubernetes 里,各种类型的资源可用量,其实是 Node 对象Status 字段的内容,比如下面这个例子:

apiVersion: v1
kind: Node
metadata:
  name: node-1
...
Status:
  Capacity:
   cpu:  2
   memory:  2049008Ki

而为了能够在上述 Status 字段里添加自定义资源的数据,你就必须使用 PATCH API 来对该 Node 对象进行更新,加上你的自定义资源的数量。这个 PATCH 操作,可以简单地使用 curl 命令来发起,如下所示:

# 启动 Kubernetes 的客户端 proxy,这样你就可以直接使用 curl 来跟 Kubernetes  的API Server 进行交互了
$ kubectl proxy

# 执行 PACTH 操作
$ curl --header "Content-Type: application/json-patch+json" \
--request PATCH \
--data '[{"op": "add", "path": "/status/capacity/nvidia.com/gpu", "value": "1"}]' \
http://localhost:8001/api/v1/nodes/<your-node-name>/status

PATCH 操作完成后,你就可以看到 Node 的 Status 变成了如下所示的内容:

apiVersion: v1
kind: Node
...
Status:
  Capacity:
   cpu:  2
   memory:  2049008Ki
   nvidia.com/gpu: 1

这样在调度器里,它就能够在缓存里记录下node-1上的nvidia.com/gpu类型的资源的数量是1。

当然,在 Kubernetes 的 GPU 支持方案里,你并不需要真正去做上述关于 Extended Resource 的这些操作。在 Kubernetes 中,对所有硬件加速设备进行管理的功能,都是由一种叫作 Device Plugin的插件来负责的。这其中,当然也就包括了对该硬件的 Extended Resource 进行汇报的逻辑。

Kubernetes 的 Device Plugin 机制,我可以用如下所示的一幅示意图来和你解释清楚。

我们先从这幅示意图的右侧开始看起。

首先,对于每一种硬件设备,都需要有它所对应的 Device Plugin 进行管理,这些 Device Plugin,都通过gRPC 的方式,同 kubelet 连接起来。以 NVIDIA GPU 为例,它对应的插件叫作NVIDIA GPU device plugin

这个 Device Plugin 会通过一个叫作 ListAndWatch的 API,定期向 kubelet 汇报该 Node 上 GPU 的列表。比如,在我们的例子里,一共有三个GPU(GPU0、GPU1和 GPU2)。这样,kubelet 在拿到这个列表之后,就可以直接在它向 APIServer 发送的心跳里,以 Extended Resource 的方式,加上这些 GPU 的数量,比如nvidia.com/gpu=3。所以说,用户在这里是不需要关心 GPU 信息向上的汇报流程的。

需要注意的是,ListAndWatch向上汇报的信息,只有本机上 GPU 的 ID 列表,而不会有任何关于 GPU 设备本身的信息。而且 kubelet 在向 API Server 汇报的时候,只会汇报该 GPU 对应的Extended Resource 的数量。当然,kubelet 本身,会将这个 GPU 的 ID 列表保存在自己的内存里,并通过 ListAndWatch API 定时更新。

而当一个 Pod 想要使用一个 GPU 的时候,它只需要像我在本文一开始给出的例子一样,在 Pod 的 limits 字段声明nvidia.com/gpu: 1。那么接下来,Kubernetes 的调度器就会从它的缓存里,寻找 GPU 数量满足条件的 Node,然后将缓存里的 GPU 数量减1,完成Pod 与 Node 的绑定。

这个调度成功后的 Pod信息,自然就会被对应的 kubelet 拿来进行容器操作。而当 kubelet 发现这个 Pod 的容器请求一个 GPU 的时候,kubelet 就会从自己持有的 GPU列表里,为这个容器分配一个GPU。此时,kubelet 就会向本机的 Device Plugin 发起一个 Allocate() 请求。这个请求携带的参数,正是即将分配给该容器的设备 ID 列表。

当 Device Plugin 收到 Allocate 请求之后,它就会根据kubelet 传递过来的设备 ID,从Device Plugin 里找到这些设备对应的设备路径和驱动目录。当然,这些信息,正是 Device Plugin 周期性的从本机查询到的。比如,在 NVIDIA Device Plugin 的实现里,它会定期访问 nvidia-docker 插件,从而获取到本机的 GPU 信息。

而被分配GPU对应的设备路径和驱动目录信息被返回给 kubelet 之后,kubelet 就完成了为一个容器分配 GPU 的操作。接下来,kubelet 会把这些信息追加在创建该容器所对应的 CRI 请求当中。这样,当这个 CRI 请求发给 Docker 之后,Docker 为你创建出来的容器里,就会出现这个 GPU 设备,并把它所需要的驱动目录挂载进去。

至此,Kubernetes 为一个Pod 分配一个 GPU 的流程就完成了。

对于其他类型硬件来说,要想在 Kubernetes 所管理的容器里使用这些硬件的话,也需要遵循上述 Device Plugin 的流程来实现如下所示的Allocate和 ListAndWatch API:

  service DevicePlugin {
        // ListAndWatch returns a stream of List of Devices
        // Whenever a Device state change or a Device disappears, ListAndWatch
        // returns the new list
        rpc ListAndWatch(Empty) returns (stream ListAndWatchResponse) {}
        // Allocate is called during container creation so that the Device
        // Plugin can run device specific operations and instruct Kubelet
        // of the steps to make the Device available in the container
        rpc Allocate(AllocateRequest) returns (AllocateResponse) {}
  }

目前,Kubernetes社区里已经实现了很多硬件插件,比如FPGASRIOVRDMA等等。感兴趣的话,你可以点击这些链接来查看这些 Device Plugin 的实现。

总结

在本篇文章中,我为你详细讲述了 Kubernetes 对 GPU 的管理方式,以及它所需要使用的 Device Plugin 机制。

需要指出的是,Device Plugin 的设计,长期以来都是以 Google Cloud 的用户需求为主导的,所以,它的整套工作机制和流程上,实际上跟学术界和工业界的真实场景还有着不小的差异。

这里最大的问题在于,GPU 等硬件设备的调度工作,实际上是由 kubelet 完成的。即,kubelet 会负责从它所持有的硬件设备列表中,为容器挑选一个硬件设备,然后调用 Device Plugin 的 Allocate API 来完成这个分配操作。可以看到,在整条链路中,调度器扮演的角色,仅仅是为 Pod 寻找到可用的、支持这种硬件设备的节点而已。

这就使得,Kubernetes 里对硬件设备的管理,只能处理“设备个数”这唯一一种情况。一旦你的设备是异构的、不能简单地用“数目”去描述具体使用需求的时候,比如,“我的 Pod 想要运行在计算能力最强的那个 GPU 上”,Device Plugin 就完全不能处理了。

更不用说,在很多场景下,我们其实希望在调度器进行调度的时候,就可以根据整个集群里的某种硬件设备的全局分布,做出一个最佳的调度选择。

此外,上述 Device Plugin 的设计,也使得 Kubernetes 里,缺乏一种能够对 Device 进行描述的 API 对象。这就使得如果你的硬件设备本身的属性比较复杂,并且 Pod 也关心这些硬件的属性的话,那么 Device Plugin 也是完全没有办法支持的。

更为棘手的是,在Device Plugin 的设计和实现中,Google 的工程师们一直不太愿意为 Allocate 和 ListAndWatch API 添加可扩展性的参数。这就使得,当你确实需要处理一些比较复杂的硬件设备使用需求时,是没有办法通过扩展 Device Plugin 的 API来实现的。

针对这些问题,RedHat 在社区里曾经大力推进过 ResourceClass的设计,试图将硬件设备的管理功能上浮到 API 层和调度层。但是,由于各方势力的反对,这个提议最后不了了之了。

所以说,目前 Kubernetes 本身的 Device Plugin 的设计,实际上能覆盖的场景是非常单一的,属于“可用”但是“不好用”的状态。并且, Device Plugin 的 API 本身的可扩展性也不是很好。这也就解释了为什么像 NVIDIA 这样的硬件厂商,实际上并没有完全基于上游的 Kubernetes 代码来实现自己的 GPU 解决方案,而是做了一定的改动,也就是 fork。这,实属不得已而为之。

思考题

请你结合自己的需求谈一谈,你希望如何对当前的 Device Plugin进行改进呢?或者说,你觉得当前的设计已经完全够用了吗?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

45-幕后英雄:SIG-Node与CRI

你好,我是张磊。今天我和你分享的主题是:幕后英雄之SIG-Node与CRI。

在前面的文章中,我为你详细讲解了关于 Kubernetes 调度和资源管理相关的内容。实际上,在调度这一步完成后,Kubernetes 就需要负责将这个调度成功的 Pod,在宿主机上创建出来,并把它所定义的各个容器启动起来。这些,都是 kubelet 这个核心组件的主要功能。

在接下来三篇文章中,我就深入到 kubelet 里面,为你详细剖析一下 Kubernetes 对容器运行时的管理能力。

在 Kubernetes 社区里,与 kubelet 以及容器运行时管理相关的内容,都属于 SIG-Node 的范畴。如果你经常参与社区的话,你可能会觉得,相比于其他每天都热闹非凡的 SIG小组,SIG-Node 是 Kubernetes 里相对沉寂也不太发声的一个小组,小组里的成员也很少在外面公开宣讲。

不过,正如我前面所介绍的,SIG-Node以及 kubelet,其实是 Kubernetes整套体系里非常核心的一个部分。 毕竟,它们才是 Kubernetes 这样一个容器编排与管理系统,跟容器打交道的主要“场所”。

而 kubelet 这个组件本身,也是 Kubernetes 里面第二个不可被替代的组件(第一个不可被替代的组件当然是 kube-apiserver)。也就是说,无论如何,我都不太建议你对 kubelet 的代码进行大量的改动。保持 kubelet 跟上游基本一致的重要性,就跟保持 kube-apiserver 跟上游一致是一个道理。

当然, kubelet 本身,也是按照“控制器”模式来工作的。它实际的工作原理,可以用如下所示的一幅示意图来表示清楚。


可以看到,kubelet 的工作核心,就是一个控制循环,即:SyncLoop(图中的大圆圈)。而驱动这个控制循环运行的事件,包括四种:

  1. Pod 更新事件;

  2. Pod 生命周期变化;

  3. kubelet 本身设置的执行周期;

  4. 定时的清理事件。

所以,跟其他控制器类似,kubelet 启动的时候,要做的第一件事情,就是设置 Listers,也就是注册它所关心的各种事件的 Informer。这些 Informer,就是 SyncLoop 需要处理的数据的来源。

此外,kubelet 还负责维护着很多很多其他的子控制循环(也就是图中的小圆圈)。这些控制循环的名字,一般被称作某某 Manager,比如 Volume Manager、Image Manager、Node Status Manager等等。

不难想到,这些控制循环的责任,就是通过控制器模式,完成 kubelet 的某项具体职责。比如 Node Status Manager,就负责响应 Node 的状态变化,然后将 Node 的状态收集起来,并通过 Heartbeat 的方式上报给 APIServer。再比如 CPU Manager,就负责维护该 Node 的 CPU 核的信息,以便在 Pod 通过 cpuset 的方式请求 CPU 核的时候,能够正确地管理 CPU 核的使用量和可用量。

那么这个 SyncLoop,又是如何根据 Pod 对象的变化,来进行容器操作的呢?

实际上,kubelet 也是通过 Watch机制,监听了与自己相关的 Pod 对象的变化。当然,这个 Watch 的过滤条件是该 Pod 的 nodeName 字段与自己相同。kubelet 会把这些 Pod 的信息缓存在自己的内存里。

而当一个 Pod 完成调度、与一个 Node 绑定起来之后, 这个 Pod 的变化就会触发 kubelet 在控制循环里注册的 Handler,也就是上图中的 HandlePods 部分。此时,通过检查该 Pod 在 kubelet 内存里的状态,kubelet 就能够判断出这是一个新调度过来的 Pod,从而触发 Handler 里 ADD 事件对应的处理逻辑。

在具体的处理过程当中,kubelet 会启动一个名叫 Pod Update Worker的、单独的 Goroutine 来完成对 Pod 的处理工作。

比如,如果是 ADD 事件的话,kubelet 就会为这个新的 Pod 生成对应的 Pod Status,检查 Pod 所声明使用的 Volume 是不是已经准备好。然后,调用下层的容器运行时(比如 Docker),开始创建这个 Pod 所定义的容器。

而如果是 UPDATE 事件的话,kubelet 就会根据 Pod 对象具体的变更情况,调用下层容器运行时进行容器的重建工作。

在这里需要注意的是,kubelet 调用下层容器运行时的执行过程,并不会直接调用 Docker 的 API,而是通过一组叫作 CRI(Container Runtime Interface,容器运行时接口)的 gRPC 接口来间接执行的。

Kubernetes 项目之所以要在 kubelet 中引入这样一层单独的抽象,当然是为了对 Kubernetes 屏蔽下层容器运行时的差异。实际上,对于 1.6版本之前的 Kubernetes 来说,它就是直接调用Docker 的 API 来创建和管理容器的。

但是,正如我在本专栏开始介绍容器背景的时候提到过的,Docker 项目风靡全球后不久,CoreOS 公司就推出了 rkt 项目来与 Docker 正面竞争。在这种背景下,Kubernetes 项目的默认容器运行时,自然也就成了两家公司角逐的重要战场。

毋庸置疑,Docker 项目必然是 Kubernetes 项目最依赖的容器运行时。但凭借与 Google 公司非同一般的关系,CoreOS 公司还是在2016年成功地将对 rkt 容器的支持,直接添加进了 kubelet 的主干代码里。

不过,这个“赶鸭子上架”的举动,并没有为 rkt 项目带来更多的用户,反而给 kubelet 的维护人员,带来了巨大的负担。

不难想象,在这种情况下, kubelet 任何一次重要功能的更新,都不得不考虑Docker 和 rkt 这两种容器运行时的处理场景,然后分别更新 Docker 和 rkt 两部分代码。

更让人为难的是,由于 rkt 项目实在太小众,kubelet 团队所有与 rkt 相关的代码修改,都必须依赖于 CoreOS 的员工才能做到。这不仅拖慢了 kubelet 的开发周期,也给项目的稳定性带来了巨大的隐患。

与此同时,在2016年,Kata Containers 项目的前身runV项目也开始逐渐成熟,这种基于虚拟化技术的强隔离容器,与 Kubernetes 和 Linux 容器项目之间具有良好的互补关系。所以,在 Kubernetes 上游,对虚拟化容器的支持很快就被提上了日程。

不过,虽然虚拟化容器运行时有各种优点,但它与 Linux 容器截然不同的实现方式,使得它跟 Kubernetes 的集成工作,比 rkt 要复杂得多。如果此时,再把对runV支持的代码也一起添加到 kubelet 当中,那么接下来kubelet 的维护工作就可以说完全没办法正常进行了。

所以,在2016年,SIG-Node 决定开始动手解决上述问题。而解决办法也很容易想到,那就是把 kubelet 对容器的操作,统一地抽象成一个接口。这样,kubelet 就只需要跟这个接口打交道了。而作为具体的容器项目,比如 Docker、 rkt、runV,它们就只需要自己提供一个该接口的实现,然后对 kubelet 暴露出 gRPC 服务即可。

这一层统一的容器操作接口,就是 CRI了。我会在下一篇文章中,为你详细讲解 CRI 的设计与具体的实现原理。

而在有了 CRI 之后,Kubernetes 以及 kubelet 本身的架构,就可以用如下所示的一幅示意图来描述。


可以看到,当 Kubernetes 通过编排能力创建了一个 Pod 之后,调度器会为这个 Pod 选择一个具体的节点来运行。这时候,kubelet 当然就会通过前面讲解过的 SyncLoop 来判断需要执行的具体操作,比如创建一个Pod。那么此时,kubelet 实际上就会调用一个叫作 GenericRuntime 的通用组件来发起创建 Pod 的 CRI 请求。

那么,这个 CRI 请求,又该由谁来响应呢?

如果你使用的容器项目是 Docker 的话,那么负责响应这个请求的就是一个叫作 dockershim 的组件。它会把 CRI 请求里的内容拿出来,然后组装成 Docker API 请求发给 Docker Daemon。

需要注意的是,在 Kubernetes 目前的实现里,dockershim 依然是 kubelet 代码的一部分。当然,在将来,dockershim 肯定会被从 kubelet 里移出来,甚至直接被废弃掉。

而更普遍的场景,就是你需要在每台宿主机上单独安装一个负责响应 CRI 的组件,这个组件,一般被称作CRI shim。顾名思义,CRI shim 的工作,就是扮演 kubelet 与容器项目之间的“垫片”(shim)。所以它的作用非常单一,那就是实现 CRI 规定的每个接口,然后把具体的 CRI 请求“翻译”成对后端容器项目的请求或者操作。

总结

在本篇文章中,我首先为你介绍了 SIG-Node 的职责,以及 kubelet 这个组件的工作原理。

接下来,我为你重点讲解了 kubelet 究竟是如何将 Kubernetes 对应用的定义,一步步转换成最终对 Docker 或者其他容器项目的API 请求的。

不难看到,在这个过程中,kubelet 的 SyncLoop 和 CRI 的设计,是其中最重要的两个关键点。也正是基于以上设计,SyncLoop 本身就要求这个控制循环是绝对不可以被阻塞的。所以,凡是在 kubelet 里有可能会耗费大量时间的操作,比如准备 Pod 的 Volume、拉取镜像等,SyncLoop 都会开启单独的 Goroutine 来进行操作。

思考题

请问,在你的项目中,你是如何部署 kubelet 这个组件的?为什么要这么做呢?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

46-解读CRI与容器运行时

你好,我是张磊。今天我和你分享的主题是:解读 CRI 与 容器运行时。

在上一篇文章中,我为你详细讲解了 kubelet 的工作原理和 CRI 的来龙去脉。在今天这篇文章中,我们就来进一步地、更深入地了解一下 CRI 的设计与工作原理。

首先,我们先来简要回顾一下有了 CRI 之后,Kubernetes 的架构图,如下所示。


在上一篇文章中我也提到了,CRI 机制能够发挥作用的核心,就在于每一种容器项目现在都可以自己实现一个 CRI shim,自行对 CRI 请求进行处理。这样,Kubernetes 就有了一个统一的容器抽象层,使得下层容器运行时可以自由地对接进入 Kubernetes 当中。

所以说,这里的 CRI shim,就是容器项目的维护者们自由发挥的“场地”了。而除了 dockershim之外,其他容器运行时的 CRI shim,都是需要额外部署在宿主机上的。

举个例子。CNCF 里的 containerd 项目,就可以提供一个典型的 CRI shim 的能力,即:将Kubernetes 发出的 CRI 请求,转换成对 containerd 的调用,然后创建出 runC 容器。而 runC项目,才是负责执行我们前面讲解过的设置容器 Namespace、Cgroups和chroot 等基础操作的组件。所以,这几层的组合关系,可以用如下所示的示意图来描述。


而作为一个 CRI shim,containerd 对 CRI 的具体实现,又是怎样的呢?

我们先来看一下 CRI 这个接口的定义。下面这幅示意图,就展示了 CRI 里主要的待实现接口。


具体地说,我们可以把 CRI 分为两组:

  • 第一组,是 RuntimeService。它提供的接口,主要是跟容器相关的操作。比如,创建和启动容器、删除容器、执行 exec 命令等等。

  • 而第二组,则是 ImageService。它提供的接口,主要是容器镜像相关的操作,比如拉取镜像、删除镜像等等。

关于容器镜像的操作比较简单,所以我们就暂且略过。接下来,我主要为你讲解一下RuntimeService部分。

在这一部分,CRI 设计的一个重要原则,就是确保这个接口本身,只关注容器,不关注 Pod。这样做的原因,也很容易理解。

第一,Pod 是 Kubernetes 的编排概念,而不是容器运行时的概念。所以,我们就不能假设所有下层容器项目,都能够暴露出可以直接映射为 Pod 的 API。

第二,如果 CRI 里引入了关于 Pod 的概念,那么接下来只要 Pod API 对象的字段发生变化,那么CRI 就很有可能需要变更。而在 Kubernetes 开发的前期,Pod 对象的变化还是比较频繁的,但对于CRI 这样的标准接口来说,这个变更频率就有点麻烦了。

所以,在 CRI 的设计里,并没有一个直接创建 Pod 或者启动 Pod 的接口。

不过,相信你也已经注意到了,CRI 里还是有一组叫作RunPodSandbox 的接口的。

这个 PodSandbox,对应的并不是 Kubernetes 里的 Pod API 对象,而只是抽取了 Pod 里的一部分与容器运行时相关的字段,比如HostName、DnsConfig、CgroupParent 等。所以说,PodSandbox 这个接口描述的,其实是 Kubernetes 将 Pod 这个概念映射到容器运行时层面所需要的字段,或者说是一个Pod 对象子集。

而作为具体的容器项目,你就需要自己决定如何使用这些字段来实现一个 Kubernetes 期望的 Pod模型。这里的原理,可以用如下所示的示意图来表示清楚。


比如,当我们执行 kubectl run 创建了一个名叫 foo 的、包括了 A、B 两个容器的 Pod 之后。这个Pod 的信息最后来到 kubelet,kubelet 就会按照图中所示的顺序来调用 CRI 接口。

在具体的 CRI shim 中,这些接口的实现是可以完全不同的。比如,如果是 Docker 项目,dockershim 就会创建出一个名叫 foo 的 Infra容器(pause 容器),用来“hold”住整个 Pod 的 Network Namespace。

而如果是基于虚拟化技术的容器,比如 Kata Containers 项目,它的 CRI 实现就会直接创建出一个轻量级虚拟机来充当 Pod。

此外,需要注意的是,在 RunPodSandbox 这个接口的实现中,你还需要调用networkPlugin.SetUpPod(…) 来为这个 Sandbox 设置网络。这个 SetUpPod(…) 方法,实际上就在执行 CNI 插件里的add(…) 方法,也就是我在前面为你讲解过的 CNI 插件为 Pod 创建网络,并且把 Infra 容器加入到网络中的操作。

备注:这里,你可以再回顾下第34篇文章《Kubernetes网络模型与CNI网络插件》中的相关内容。

接下来,kubelet 继续调用 CreateContainer 和 StartContainer 接口来创建和启动容器 A、B。对应到 dockershim里,就是直接启动A,B两个 Docker 容器。所以最后,宿主机上会出现三个 Docker 容器组成这一个 Pod。

而如果是 Kata Containers 的话,CreateContainer和StartContainer接口的实现,就只会在前面创建的轻量级虚拟机里创建两个 A、B 容器对应的 Mount Namespace。所以,最后在宿主机上,只会有一个叫作 foo 的轻量级虚拟机在运行。关于像 Kata Containers 或者 gVisor 这种所谓的安全容器项目,我会在下一篇文章中为你详细介绍。

除了上述对容器生命周期的实现之外,CRI shim 还有一个重要的工作,就是如何实现 exec、logs 等接口。这些接口跟前面的操作有一个很大的不同,就是这些gRPC 接口调用期间,kubelet 需要跟容器项目维护一个长连接来传输数据。这种 API,我们就称之为Streaming API。

CRI shim 里对 Streaming API 的实现,依赖于一套独立的 Streaming Server 机制。这一部分原理,可以用如下所示的示意图来为你描述。


可以看到,当我们对一个容器执行 kubectl exec 命令的时候,这个请求首先交给 API Server,然后 API Server 就会调用 kubelet 的 Exec API。

这时,kubelet就会调用 CRI 的 Exec 接口,而负责响应这个接口的,自然就是具体的 CRI shim。

但在这一步,CRI shim 并不会直接去调用后端的容器项目(比如 Docker )来进行处理,而只会返回一个 URL 给 kubelet。这个 URL,就是该 CRI shim 对应的 Streaming Server 的地址和端口。

而 kubelet 在拿到这个 URL 之后,就会把它以 Redirect 的方式返回给 API Server。所以这时候,API Server 就会通过重定向来向 Streaming Server 发起真正的 /exec 请求,与它建立长连接。

当然,这个 Streaming Server 本身,是需要通过使用 SIG-Node 为你维护的 Streaming API 库来实现的。并且,Streaming Server 会在 CRI shim 启动时就一起启动。此外,Stream Server 这一部分具体怎么实现,完全可以由 CRI shim 的维护者自行决定。比如,对于Docker 项目来说,dockershim 就是直接调用 Docker 的 Exec API 来作为实现的。

以上,就是CRI 的设计以及具体的工作原理了。

总结

在本篇文章中,我为你详细解读了 CRI 的设计和具体工作原理,并为你梳理了实现CRI 接口的核心流程。

从这些讲解中不难看出,CRI 这个接口的设计,实际上还是比较宽松的。这就意味着,作为容器项目的维护者,我在实现 CRI 的具体接口时,往往拥有着很高的自由度,这个自由度不仅包括了容器的生命周期管理,也包括了如何将 Pod 映射成为我自己的实现,还包括了如何调用 CNI 插件来为 Pod 设置网络的过程。

所以说,当你对容器这一层有特殊的需求时,我一定优先建议你考虑实现一个自己的 CRI shim ,而不是修改 kubelet 甚至容器项目的代码。这样通过插件的方式定制 Kubernetes 的做法,也是整个 Kubernetes 社区最鼓励和推崇的一个最佳实践。这也正是为什么像 Kata Containers、gVisor 甚至虚拟机这样的“非典型”容器,都可以无缝接入到 Kubernetes 项目里的重要原因。

思考题

请你思考一下,我前面讲解过的Device Plugin 为容器分配的 GPU 信息,是通过 CRI 的哪个接口传递给 dockershim,最后交给 Docker API 的呢?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

47-绝不仅仅是安全:KataContainers与gVisor

你好,我是张磊。今天我和你分享的主题是:绝不仅仅是安全之Kata Containers 与 gVisor。

在上一篇文章中,我为你详细地讲解了 kubelet 和 CRI 的设计和具体的工作原理。而在讲解 CRI 的诞生背景时,我也提到过,这其中的一个重要推动力,就是基于虚拟化或者独立内核的安全容器项目的逐渐成熟。

使用虚拟化技术来做一个像 Docker 一样的容器项目,并不是一个新鲜的主意。早在 Docker 项目发布之后,Google 公司就开源了一个实验性的项目,叫作 novm。这,可以算是试图使用常规的虚拟化技术来运行 Docker 镜像的第一次尝试。不过,novm 在开源后不久,就被放弃了,这对于 Google 公司来说或许不算是什么新鲜事,但是 novm 的昙花一现,还是激发出了很多内核开发者的灵感。

所以在2015年,几乎在同一个星期,Intel OTC (Open Source Technology Center) 和国内的 HyperHQ 团队同时开源了两个基于虚拟化技术的容器实现,分别叫做 Intel Clear Container 和 runV 项目。

而在2017年,借着 Kubernetes 的东风,这两个相似的容器运行时项目在中立基金会的撮合下最终合并,就成了现在大家耳熟能详的 Kata Containers 项目。 由于 Kata Containers 的本质就是一个精简后的轻量级虚拟机,所以它的特点,就是“像虚拟机一样安全,像容器一样敏捷”。

而在2018年,Google 公司则发布了一个名叫 gVisor 的项目。gVisor 项目给容器进程配置一个用 Go 语言实现的、运行在用户态的、极小的“独立内核”。这个内核对容器进程暴露 Linux 内核 ABI,扮演着“Guest Kernel”的角色,从而达到了将容器和宿主机隔离开的目的。

不难看到,无论是 Kata Containers,还是 gVisor,它们实现安全容器的方法其实是殊途同归的。这两种容器实现的本质,都是给进程分配了一个独立的操作系统内核,从而避免了让容器共享宿主机的内核。这样,容器进程能够看到的攻击面,就从整个宿主机内核变成了一个极小的、独立的、以容器为单位的内核,从而有效解决了容器进程发生“逃逸”或者夺取整个宿主机的控制权的问题。这个原理,可以用如下所示的示意图来表示清楚。

而它们的区别在于,Kata Containers 使用的是传统的虚拟化技术,通过虚拟硬件模拟出了一台“小虚拟机”,然后在这个小虚拟机里安装了一个裁剪后的 Linux 内核来实现强隔离。

而 gVisor 的做法则更加激进,Google 的工程师直接用 Go 语言“模拟”出了一个运行在用户态的操作系统内核,然后通过这个模拟的内核来代替容器进程向宿主机发起有限的、可控的系统调用。

接下来,我就来为你详细解读一下 KataContainers 和 gVisor 具体的设计原理。

首先,我们来看 KataContainers。它的工作原理可以用如下所示的示意图来描述。

我们前面说过,Kata Containers 的本质,就是一个轻量化虚拟机。所以当你启动一个 Kata Containers 之后,你其实就会看到一个正常的虚拟机在运行。这也就意味着,一个标准的虚拟机管理程序(Virtual Machine Manager, VMM)是运行 Kata Containers 必备的一个组件。在我们上面图中,使用的 VMM 就是 Qemu。

而使用了虚拟机作为进程的隔离环境之后,Kata Containers 原生就带有了 Pod 的概念。即:这个Kata Containers 启动的虚拟机,就是一个 Pod;而用户定义的容器,就是运行在这个轻量级虚拟机里的进程。在具体实现上,Kata Containers 的虚拟机里会有一个特殊的 Init 进程负责管理虚拟机里面的用户容器,并且只为这些容器开启 Mount Namespace。所以,这些用户容器之间,原生就是共享 Network 以及其他Namespace 的。

此外,为了跟上层编排框架比如 Kubernetes 进行对接,Kata Containers 项目会启动一系列跟用户容器对应的 shim 进程,来负责操作这些用户容器的生命周期。当然,这些操作,实际上还是要靠虚拟机里的 Init 进程来帮你做到。

而在具体的架构上,Kata Containers的实现方式同一个正常的虚拟机其实也非常类似。这里的原理,可以用如下所示的一幅示意图来表示。

可以看到,当 Kata Containers 运行起来之后,虚拟机里的用户进程(容器),实际上只能看到虚拟机里的、被裁减过的 Guest Kernel,以及通过 Hypervisor 虚拟出来的硬件设备。

而为了能够对这个虚拟机的 I/O 性能进行优化,Kata Containers 也会通过 vhost 技术(比如:vhost-user)来实现 Guest 与 Host 之间的高效的网络通信,并且使用 PCI Passthrough (PCI 穿透)技术来让 Guest 里的进程直接访问到宿主机上的物理设备。这些架构设计与实现,其实跟常规虚拟机的优化手段是基本一致的。

相比之下,gVisor 的设计其实要更加“激进”一些。它的原理,可以用如下所示的示意图来表示清楚。

gVisor工作的核心,在于它为应用进程、也就是用户容器,启动了一个名叫 Sentry 的进程。 而Sentry 进程的主要职责,就是提供一个传统的操作系统内核的能力,即:运行用户程序,执行系统调用。所以说,Sentry 并不是使用 Go 语言重新实现了一个完整的 Linux 内核,而只是一个对应用进程“冒充”内核的系统组件。

在这种设计思想下,我们就不难理解,Sentry 其实需要自己实现一个完整的 Linux 内核网络栈,以便处理应用进程的通信请求。然后,把封装好的二层帧直接发送给 Kubernetes 设置的 Pod 的Network Namespace 即可。

此外,Sentry 对于Volume 的操作,则需要通过 9p 协议交给一个叫做 Gofer 的代理进程来完成。Gofer 会代替应用进程直接操作宿主机上的文件,并依靠seccomp机制将自己的能力限制在最小集,从而防止恶意应用进程通过 Gofer 来从容器中“逃逸”出去。

而在具体的实现上,gVisor 的 Sentry 进程,其实还分为两种不同的实现方式。这里的工作原理,可以用下面的示意图来描述清楚。

第一种实现方式,是使用Ptrace机制来拦截用户应用的系统调用(System Call),然后把这些系统调用交给 Sentry 来进行处理。

这个过程,对于应用进程来说,是完全透明的。而 Sentry 接下来,则会扮演操作系统的角色,在用户态执行用户程序,然后仅在需要的时候,才向宿主机发起 Sentry 自己所需要执行的系统调用。这,就是 gVisor 对用户应用进程进行强隔离的主要手段。不过, Ptrace 进行系统调用拦截的性能实在是太差,仅能供 Demo 时使用。

第二种实现方式,则更加具有普适性。它的工作原理如下图所示。

在这种实现里,Sentry 会使用 KVM 来进行系统调用的拦截,这个性能比 Ptrace 就要好很多了。

当然,为了能够做到这一点,Sentry 进程就必须扮演一个 Guest Kernel 的角色,负责执行用户程序,发起系统调用。而这些系统调用被 KVM 拦截下来,还是继续交给 Sentry 进行处理。只不过在这时候,Sentry 就切换成了一个普通的宿主机进程的角色,来向宿主机发起它所需要的系统调用。

可以看到,在这种实现里,Sentry 并不会真的像虚拟机那样去虚拟出硬件设备、安装 Guest 操作系统。它只是借助 KVM 进行系统调用的拦截,以及处理地址空间切换等细节。

值得一提的是,在 Google 内部,他们也是使用的第二种基于 Hypervisor 的gVisor 实现。只不过 Google 内部有自己研发的 Hypervisor,所以要比 KVM 实现的性能还要好。

通过以上的讲述,相信你对 Kata Containers 和 gVisor 的实现原理,已经有一个感性的认识了。需要指出的是,到目前为止,gVisor 的实现依然不是非常完善,有很多 Linux系统调用它还不支持;有很多应用,在 gVisor 里还没办法运行起来。 此外,gVisor也暂时没有实现一个 Pod 多个容器的支持。当然,在后面的发展中,这些工程问题一定会逐渐解决掉的。

另外,你可能还听说过 AWS 在2018年末发布的一个叫做 Firecracker 的安全容器项目。这个项目的核心,其实是一个用 Rust 语言重新编写的 VMM(即:虚拟机管理器)。这就意味着, Firecracker 和 Kata Containers 的本质原理,其实是一样的。只不过, Kata Containers 默认使用的 VMM 是 Qemu,而 Firecracker,则使用自己编写的 VMM。所以,理论上,Kata Containers 也可以使用 Firecracker 运行起来。

总结

在本篇文章中,我为你详细地介绍了拥有独立内核的安全容器项目,对比了 KataContainers 和 gVisor 的设计与实现细节。

在性能上,KataContainers 和 KVM 实现的 gVisor 基本不分伯仲,在启动速度和占用资源上,基于用户态内核的 gVisor 还略胜一筹。但是,对于系统调用密集的应用,比如重 I/O 或者重网络的应用,gVisor 就会因为需要频繁拦截系统调用而出现性能急剧下降的情况。此外,gVisor 由于要自己使用 Sentry 去模拟一个Linux 内核,所以它能支持的系统调用是有限的,只是 Linux 系统调用的一个子集。

不过,gVisor 虽然现在没有任何优势,但是这种通过在用户态运行一个操作系统内核,来为应用进程提供强隔离的思路,的确是未来安全容器进一步演化的一个非常有前途的方向。

值得一提的是,Kata Containers 团队在 gVisor 之前,就已经 Demo 了一个名叫 Linuxd 的项目。这个项目,使用了 User Mode Linux (UML)技术,在用户态运行起了一个真正的 Linux Kernel 来为应用进程提供强隔离,从而避免了重新实现 Linux Kernel 带来的各种麻烦。

有兴趣的话,你可以在这里查看这个演讲。我相信,这个方向,应该才是安全容器进化的未来。这比 Unikernels 这种根本不适合实际场景中使用的思路,要靠谱得多。

本篇图片出处均引自 Kata Containers 的官方对比资料

思考题

安全容器的意义,绝不仅仅止于安全。你可以想象一下这样一个场景:比如,你的宿主机的 Linux 内核版本是3.6,但是应用却必须要求 Linux 内核版本是4.0。这时候,你就可以把这个应用运行在一个 KataContainers 里。那么请问,你觉得使用 gVisor 是否也能提供这种能力呢?原因是什么呢?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

48-Prometheus、MetricsServer与Kubernetes监控体系

你好,我是张磊。今天我和你分享的主题是:Prometheus、Metrics Server与Kubernetes监控体系。

通过前面的文章,我已经和你分享过了Kubernetes 的核心架构,编排概念,以及具体的设计与实现。接下来,我会用3篇文章,为你介绍 Kubernetes 监控相关的一些核心技术。

首先需要明确指出的是,Kubernetes 项目的监控体系曾经非常繁杂,在社区中也有很多方案。但这套体系发展到今天,已经完全演变成了以Prometheus 项目为核心的一套统一的方案。

在这里,可能有一些同学对 Prometheus 项目还太不熟悉。所以,我先来简单为你介绍一下这个项目。

实际上,Prometheus 项目是当年 CNCF 基金会起家时的“第二把交椅”。而这个项目发展到今天,已经全面接管了 Kubernetes 项目的整套监控体系。

比较有意思的是,Prometheus项目与 Kubernetes 项目一样,也来自于 Google 的 Borg 体系,它的原型系统,叫作BorgMon,是一个几乎与 Borg 同时诞生的内部监控系统。而 Prometheus 项目的发起原因也跟 Kubernetes 很类似,都是希望通过对用户更友好的方式,将 Google 内部系统的设计理念,传递给用户和开发者。

作为一个监控系统,Prometheus 项目的作用和工作方式,其实可以用如下所示的一张官方示意图来解释。


可以看到,Prometheus 项目工作的核心,是使用 Pull (抓取)的方式去搜集被监控对象的 Metrics 数据(监控指标数据),然后,再把这些数据保存在一个 TSDB (时间序列数据库,比如 OpenTSDB、InfluxDB 等)当中,以便后续可以按照时间进行检索。

有了这套核心监控机制, Prometheus 剩下的组件就是用来配合这套机制的运行。比如 Pushgateway,可以允许被监控对象以Push 的方式向 Prometheus 推送 Metrics 数据。而Alertmanager,则可以根据 Metrics 信息灵活地设置报警。当然, Prometheus 最受用户欢迎的功能,还是通过 Grafana 对外暴露出的、可以灵活配置的监控数据可视化界面。

有了 Prometheus 之后,我们就可以按照Metrics 数据的来源,来对 Kubernetes 的监控体系做一个汇总了。

第一种 Metrics,是宿主机的监控数据。这部分数据的提供,需要借助一个由 Prometheus 维护的Node Exporter 工具。一般来说,Node Exporter 会以 DaemonSet 的方式运行在宿主机上。其实,所谓的 Exporter,就是代替被监控对象来对 Prometheus 暴露出可以被“抓取”的 Metrics 信息的一个辅助进程。

而 Node Exporter 可以暴露给 Prometheus 采集的Metrics 数据, 也不单单是节点的负载(Load)、CPU 、内存、磁盘以及网络这样的常规信息,它的 Metrics 指标可以说是“包罗万象”,你可以查看这个列表来感受一下。

第二种 Metrics,是来自于 Kubernetes 的 API Server、kubelet 等组件的/metrics API。除了常规的 CPU、内存的信息外,这部分信息还主要包括了各个组件的核心监控指标。比如,对于 API Server 来说,它就会在/metrics API 里,暴露出各个 Controller 的工作队列(Work Queue)的长度、请求的 QPS 和延迟数据等等。这些信息,是检查 Kubernetes 本身工作情况的主要依据。

第三种 Metrics,是 Kubernetes 相关的监控数据。这部分数据,一般叫作 Kubernetes 核心监控数据(core metrics)。这其中包括了 Pod、Node、容器、Service 等主要 Kubernetes 核心概念的 Metrics。

其中,容器相关的 Metrics 主要来自于 kubelet 内置的 cAdvisor 服务。在 kubelet 启动后,cAdvisor 服务也随之启动,而它能够提供的信息,可以细化到每一个容器的CPU 、文件系统、内存、网络等资源的使用情况。

需要注意的是,这里提到的 Kubernetes 核心监控数据,其实使用的是 Kubernetes 的一个非常重要的扩展能力,叫作 Metrics Server。

Metrics Server 在 Kubernetes 社区的定位,其实是用来取代 Heapster 这个项目的。在 Kubernetes 项目发展的初期,Heapster 是用户获取 Kubernetes 监控数据(比如 Pod 和 Node的资源使用情况) 的主要渠道。而后面提出来的 Metrics Server,则把这些信息,通过标准的 Kubernetes API 暴露了出来。这样,Metrics 信息就跟 Heapster 完成了解耦,允许 Heapster 项目慢慢退出舞台。

而有了 Metrics Server 之后,用户就可以通过标准的 Kubernetes API 来访问到这些监控数据了。比如,下面这个 URL:

http://127.0.0.1:8001/apis/metrics.k8s.io/v1beta1/namespaces/<namespace-name>/pods/<pod-name>

当你访问这个 Metrics API时,它就会为你返回一个 Pod 的监控数据,而这些数据,其实是从 kubelet 的 Summary API (即<kubelet_ip>:<kubelet_port>/stats/summary)采集而来的。Summary API 返回的信息,既包括了 cAdVisor的监控数据,也包括了 kubelet 本身汇总的信息。

需要指出的是, Metrics Server 并不是 kube-apiserver 的一部分,而是通过 Aggregator 这种插件机制,在独立部署的情况下同 kube-apiserver 一起统一对外服务的。

这里,Aggregator APIServer 的工作原理,可以用如下所示的一幅示意图来表示清楚:

备注:图片出处https://blog.jetstack.io/blog/resource-and-custom-metrics-hpa-v2/

可以看到,当Kubernetes 的 API Server 开启了 Aggregator 模式之后,你再访问apis/metrics.k8s.io/v1beta1的时候,实际上访问到的是一个叫作kube-aggregator 的代理。而kube-apiserver,正是这个代理的一个后端;而 Metrics Server,则是另一个后端。

而且,在这个机制下,你还可以添加更多的后端给这个 kube-aggregator。所以kube-aggregator其实就是一个根据 URL 选择具体的 API 后端的代理服务器。通过这种方式,我们就可以很方便地扩展 Kubernetes 的 API 了。

而 Aggregator 模式的开启也非常简单:

  • 如果你是使用 kubeadm 或者官方的kube-up.sh 脚本部署Kubernetes集群的话,Aggregator 模式就是默认开启的;
  • 如果是手动 DIY 搭建的话,你就需要在 kube-apiserver 的启动参数里加上如下所示的配置:
--requestheader-client-ca-file=<path to aggregator CA cert>
--requestheader-allowed-names=front-proxy-client
--requestheader-extra-headers-prefix=X-Remote-Extra-
--requestheader-group-headers=X-Remote-Group
--requestheader-username-headers=X-Remote-User
--proxy-client-cert-file=<path to aggregator proxy cert>
--proxy-client-key-file=<path to aggregator proxy key>

而这些配置的作用,主要就是为 Aggregator 这一层设置对应的 Key 和 Cert 文件。而这些文件的生成,就需要你自己手动完成了,具体流程请参考这篇官方文档

Aggregator 功能开启之后,你只需要将 Metrics Server 的 YAML 文件部署起来,如下所示:

$ git clone https://github.com/kubernetes-incubator/metrics-server
$ cd metrics-server
$ kubectl create -f deploy/1.8+/

接下来,你就会看到metrics.k8s.io这个API 出现在了你的 Kubernetes API 列表当中。

在理解了Prometheus 关心的三种监控数据源,以及 Kubernetes 的核心 Metrics 之后,作为用户,你其实要做的就是将 Prometheus Operator 在 Kubernetes 集群里部署起来。然后,按照本篇文章一开始介绍的架构,把上述Metrics 源配置起来,让 Prometheus 自己去进行采集即可。

在后续的文章中,我会为你进一步剖析 Kubernetes 监控体系以及自定义Metrics (自定义监控指标)的具体技术点。

总结

在本篇文章中,我主要为你介绍了 Kubernetes 当前监控体系的设计,介绍了 Prometheus 项目在这套体系中的地位,讲解了以 Prometheus 为核心的监控系统的架构设计。

然后,我为你详细地解读了 Kubernetes 核心监控数据的来源,即:Metrics Server的具体工作原理,以及 Aggregator APIServer 的设计思路。

通过以上讲述,我希望你能够对 Kubernetes 的监控体系形成一个整体的认知,体会到 Kubernetes 社区在监控这个事情上,全面以 Prometheus 项目为核心进行建设的大方向。

最后,在具体的监控指标规划上,我建议你遵循业界通用的 USE 原则和 RED 原则。

其中,USE 原则指的是,按照如下三个维度来规划资源监控指标:

  1. 利用率(Utilization),资源被有效利用起来提供服务的平均时间占比;

  2. 饱和度(Saturation),资源拥挤的程度,比如工作队列的长度;

  3. 错误率(Errors),错误的数量。

而 RED 原则指的是,按照如下三个维度来规划服务监控指标:

  1. 每秒请求数量(Rate);

  2. 每秒错误数量(Errors);

  3. 服务响应时间(Duration)。

不难发现, USE 原则主要关注的是“资源”,比如节点和容器的资源使用情况,而 RED 原则主要关注的是“服务”,比如 kube-apiserver 或者某个应用的工作情况。这两种指标,在我今天为你讲解的 Kubernetes + Prometheus 组成的监控体系中,都是可以完全覆盖到的。

思考题

在监控体系中,对于数据的采集,其实既有 Prometheus 这种 Pull 模式,也有 Push 模式。请问,你如何看待这两种模式的异同和优缺点呢?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

49-CustomMetrics-让AutoScaling不再“食之无味”

你好,我是张磊。今天我和你分享的主题是:Custom Metrics,让Auto Scaling不再“食之无味”。

在上一篇文章中,我为你详细讲述了 Kubernetes 里的核心监控体系的架构。不难看到,Prometheus 项目在其中占据了最为核心的位置。

实际上,借助上述监控体系,Kubernetes 就可以为你提供一种非常有用的能力,那就是 Custom Metrics,自定义监控指标。

在过去的很多 PaaS 项目中,其实都有一种叫作 Auto Scaling,即自动水平扩展的功能。只不过,这个功能往往只能依据某种指定的资源类型执行水平扩展,比如 CPU 或者 Memory 的使用值。

而在真实的场景中,用户需要进行Auto Scaling 的依据往往是自定义的监控指标。比如,某个应用的等待队列的长度,或者某种应用相关资源的使用情况。这些复杂多变的需求,在传统 PaaS项目和其他容器编排项目里,几乎是不可能轻松支持的。

而凭借强大的 API 扩展机制,Custom Metrics已经成为了 Kubernetes 的一项标准能力。并且,Kubernetes 的自动扩展器组件 Horizontal Pod Autoscaler (HPA), 也可以直接使用Custom Metrics来执行用户指定的扩展策略,这里的整个过程都是非常灵活和可定制的。

不难想到,Kubernetes 里的 Custom Metrics 机制,也是借助Aggregator APIServer 扩展机制来实现的。这里的具体原理是,当你把 Custom Metrics APIServer 启动之后,Kubernetes 里就会出现一个叫作custom.metrics.k8s.io的 API。而当你访问这个 URL 时,Aggregator就会把你的请求转发给Custom Metrics APIServer 。

而Custom Metrics APIServer 的实现,其实就是一个 Prometheus 项目的 Adaptor。

比如,现在我们要实现一个根据指定 Pod 收到的 HTTP 请求数量来进行 Auto Scaling 的 Custom Metrics,这个 Metrics 就可以通过访问如下所示的自定义监控 URL 获取到:

https://<apiserver_ip>/apis/custom-metrics.metrics.k8s.io/v1beta1/namespaces/default/pods/sample-metrics-app/http_requests

这里的工作原理是,当你访问这个 URL 的时候,Custom Metrics APIServer就会去 Prometheus 里查询名叫sample-metrics-app这个Pod 的http_requests指标的值,然后按照固定的格式返回给访问者。

当然,http_requests指标的值,就需要由 Prometheus 按照我在上一篇文章中讲到的核心监控体系,从目标 Pod 上采集。

这里具体的做法有很多种,最普遍的做法,就是让 Pod 里的应用本身暴露出一个/metrics API,然后在这个 API 里返回自己收到的HTTP的请求的数量。所以说,接下来 HPA 只需要定时访问前面提到的自定义监控 URL,然后根据这些值计算是否要执行 Scaling 即可。

接下来,我通过一个具体的实例,来为你讲解一下 Custom Metrics 具体的使用方式。这个实例的 GitHub 库在这里,你可以点击链接查看。在这个例子中,我依然会假设你的集群是 kubeadm 部署出来的,所以 Aggregator 功能已经默认开启了。

备注:我们这里使用的实例,fork 自 Lucas 在上高中时做的一系列Kubernetes 指南。

首先,我们当然是先部署 Prometheus 项目。这一步,我当然会使用 Prometheus Operator来完成,如下所示:

$ kubectl apply -f demos/monitoring/prometheus-operator.yaml
clusterrole "prometheus-operator" created
serviceaccount "prometheus-operator" created
clusterrolebinding "prometheus-operator" created
deployment "prometheus-operator" created

$ kubectl apply -f demos/monitoring/sample-prometheus-instance.yaml
clusterrole "prometheus" created
serviceaccount "prometheus" created
clusterrolebinding "prometheus" created
prometheus "sample-metrics-prom" created
service "sample-metrics-prom" created

第二步,我们需要把 Custom Metrics APIServer 部署起来,如下所示:

$ kubectl apply -f demos/monitoring/custom-metrics.yaml
namespace "custom-metrics" created
serviceaccount "custom-metrics-apiserver" created
clusterrolebinding "custom-metrics:system:auth-delegator" created
rolebinding "custom-metrics-auth-reader" created
clusterrole "custom-metrics-read" created
clusterrolebinding "custom-metrics-read" created
deployment "custom-metrics-apiserver" created
service "api" created
apiservice "v1beta1.custom-metrics.metrics.k8s.io" created
clusterrole "custom-metrics-server-resources" created
clusterrolebinding "hpa-controller-custom-metrics" created

第三步,我们需要为 Custom Metrics APIServer 创建对应的 ClusterRoleBinding,以便能够使用curl来直接访问 Custom Metrics 的 API:

$ kubectl create clusterrolebinding allowall-cm --clusterrole custom-metrics-server-resources --user system:anonymous
clusterrolebinding "allowall-cm" created

第四步,我们就可以把待监控的应用和 HPA 部署起来了,如下所示:

$ kubectl apply -f demos/monitoring/sample-metrics-app.yaml
deployment "sample-metrics-app" created
service "sample-metrics-app" created
servicemonitor "sample-metrics-app" created
horizontalpodautoscaler "sample-metrics-app-hpa" created
ingress "sample-metrics-app" created

这里,我们需要关注一下 HPA 的配置,如下所示:

kind: HorizontalPodAutoscaler
apiVersion: autoscaling/v2beta1
metadata:
  name: sample-metrics-app-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: sample-metrics-app
  minReplicas: 2
  maxReplicas: 10
  metrics:
  - type: Object
    object:
      target:
        kind: Service
        name: sample-metrics-app
      metricName: http_requests
      targetValue: 100

可以看到,HPA 的配置,就是你设置 Auto Scaling 规则的地方。

比如,scaleTargetRef字段,就指定了被监控的对象是名叫sample-metrics-app的 Deployment,也就是我们上面部署的被监控应用。并且,它最小的实例数目是2,最大是10。

在metrics字段,我们指定了这个 HPA 进行 Scale 的依据,是名叫http_requests的 Metrics。而获取这个 Metrics 的途径,则是访问名叫sample-metrics-app的 Service。

有了这些字段里的定义, HPA 就可以向如下所示的 URL 发起请求来获取 Custom Metrics 的值了:

https://<apiserver_ip>/apis/custom-metrics.metrics.k8s.io/v1beta1/namespaces/default/services/sample-metrics-app/http_requests

需要注意的是,上述这个 URL 对应的被监控对象,是我们的应用对应的 Service。这跟本文一开始举例用到的 Pod 对应的 Custom Metrics URL 是不一样的。当然,对于一个多实例应用来说,通过 Service 来采集 Pod 的 Custom Metrics 其实才是合理的做法。

这时候,我们可以通过一个名叫hey的测试工具来为我们的应用增加一些访问压力,具体做法如下所示:

$ # Install hey
$ docker run -it -v /usr/local/bin:/go/bin golang:1.8 go get github.com/rakyll/hey

$ export APP_ENDPOINT=$(kubectl get svc sample-metrics-app -o template --template {{.spec.clusterIP}}); echo ${APP_ENDPOINT}
$ hey -n 50000 -c 1000 http://${APP_ENDPOINT}

与此同时,如果你去访问应用 Service 的 Custom Metircs URL,就会看到这个 URL 已经可以为你返回应用收到的 HTTP 请求数量了,如下所示:

$ curl -sSLk https://<apiserver_ip>/apis/custom-metrics.metrics.k8s.io/v1beta1/namespaces/default/services/sample-metrics-app/http_requests
{
  "kind": "MetricValueList",
  "apiVersion": "custom-metrics.metrics.k8s.io/v1beta1",
  "metadata": {
    "selfLink": "/apis/custom-metrics.metrics.k8s.io/v1beta1/namespaces/default/services/sample-metrics-app/http_requests"
  },
  "items": [
    {
      "describedObject": {
        "kind": "Service",
        "name": "sample-metrics-app",
        "apiVersion": "/__internal"
      },
      "metricName": "http_requests",
      "timestamp": "2018-11-30T20:56:34Z",
      "value": "501484m"
    }
  ]
}

这里需要注意的是,Custom Metrics API 为你返回的 Value 的格式。

在为被监控应用编写/metrics API 的返回值时,我们其实比较容易计算的,是该 Pod 收到的 HTTP request 的总数。所以,我们这个应用的代码其实是如下所示的样子:

  if (request.url == "/metrics") {
    response.end("# HELP http_requests_total The amount of requests served by the server in total\n# TYPE http_requests_total counter\nhttp_requests_total " + totalrequests + "\n");
    return;
  }

可以看到,我们的应用在/metrics 对应的 HTTP response 里返回的,其实是http_requests_total的值。这,也就是 Prometheus 收集到的值。

而 Custom Metrics APIServer 在收到对http_requests指标的访问请求之后,它会从Prometheus 里查询http_requests_total的值,然后把它折算成一个以时间为单位的请求率,最后把这个结果作为http_requests指标对应的值返回回去。

所以说,我们在对前面的 Custom Metircs URL 进行访问时,会看到值是501484m,这里的格式,其实就是milli-requests,相当于是在过去两分钟内,每秒有501个请求。这样,应用的开发者就无需关心如何计算每秒的请求个数了。而这样的“请求率”的格式,是可以直接被 HPA 拿来使用的。

这时候,如果你同时查看 Pod 的个数的话,就会看到 HPA 开始增加 Pod 的数目了。

不过,在这里你可能会有一个疑问,Prometheus 项目,又是如何知道采集哪些 Pod 的 /metrics API 作为监控指标的来源呢。

实际上,如果仔细观察一下我们前面创建应用的输出,你会看到有一个类型是ServiceMonitor的对象也被创建了出来。它的 YAML 文件如下所示:

apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: sample-metrics-app
  labels:
    service-monitor: sample-metrics-app
spec:
  selector:
    matchLabels:
      app: sample-metrics-app
  endpoints:
  - port: web

这个ServiceMonitor对象,正是 Prometheus Operator 项目用来指定被监控 Pod 的一个配置文件。可以看到,我其实是通过Label Selector 为Prometheus 来指定被监控应用的。

总结

在本篇文章中,我为你详细讲解了 Kubernetes 里对自定义监控指标,即 Custom Metrics 的设计与实现机制。这套机制的可扩展性非常强,也终于使得Auto Scaling 在 Kubernetes 里面不再是一个“食之无味”的鸡肋功能了。

另外可以看到,Kubernetes 的 Aggregator APIServer,是一个非常行之有效的 API 扩展机制。而且,Kubernetes 社区已经为你提供了一套叫作 KubeBuilder 的工具库,帮助你生成一个 API Server 的完整代码框架,你只需要在里面添加自定义 API,以及对应的业务逻辑即可。

思考题

在你的业务场景中,你希望使用什么样的指标作为 Custom Metrics ,以便对 Pod 进行 Auto Scaling 呢?怎么获取到这个指标呢?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

50-让日志无处可逃:容器日志收集与管理

你好,我是张磊。今天我和你分享的主题是:让日志无处可逃之容器日志收集与管理。

在前面的文章中,我为你详细讲解了 Kubernetes 的核心监控体系和自定义监控体系的设计与实现思路。而在本篇文章里,我就来为你详细介绍一下Kubernetes 里关于容器日志的处理方式。

首先需要明确的是,Kubernetes 里面对容器日志的处理方式,都叫作cluster-level-logging,即:这个日志处理系统,与容器、Pod以及Node的生命周期都是完全无关的。这种设计当然是为了保证,无论是容器挂了、Pod 被删除,甚至节点宕机的时候,应用的日志依然可以被正常获取到。

而对于一个容器来说,当应用把日志输出到 stdout 和 stderr 之后,容器项目在默认情况下就会把这些日志输出到宿主机上的一个 JSON 文件里。这样,你通过 kubectl logs 命令就可以看到这些容器的日志了。

上述机制,就是我们今天要讲解的容器日志收集的基础假设。而如果你的应用是把文件输出到其他地方,比如直接输出到了容器里的某个文件里,或者输出到了远程存储里,那就属于特殊情况了。当然,我在文章里也会对这些特殊情况的处理方法进行讲述。

而 Kubernetes 本身,实际上是不会为你做容器日志收集工作的,所以为了实现上述cluster-level-logging,你需要在部署集群的时候,提前对具体的日志方案进行规划。而 Kubernetes 项目本身,主要为你推荐了三种日志方案。

第一种,在 Node 上部署 logging agent,将日志文件转发到后端存储里保存起来。这个方案的架构图如下所示。

不难看到,这里的核心就在于 logging agent ,它一般都会以 DaemonSet 的方式运行在节点上,然后将宿主机上的容器日志目录挂载进去,最后由 logging-agent 把日志转发出去。

举个例子,我们可以通过 Fluentd 项目作为宿主机上的 logging-agent,然后把日志转发到远端的 ElasticSearch 里保存起来供将来进行检索。具体的操作过程,你可以通过阅读这篇文档来了解。另外,在很多 Kubernetes 的部署里,会自动为你启用 logrotate,在日志文件超过10MB 的时候自动对日志文件进行 rotate 操作。

可以看到,在 Node 上部署 logging agent最大的优点,在于一个节点只需要部署一个 agent,并且不会对应用和 Pod 有任何侵入性。所以,这个方案,在社区里是最常用的一种。

但是也不难看到,这种方案的不足之处就在于,它要求应用输出的日志,都必须是直接输出到容器的 stdout 和 stderr 里。

所以,Kubernetes 容器日志方案的第二种,就是对这种特殊情况的一个处理,即:当容器的日志只能输出到某些文件里的时候,我们可以通过一个 sidecar 容器把这些日志文件重新输出到 sidecar 的 stdout 和 stderr 上,这样就能够继续使用第一种方案了。这个方案的具体工作原理,如下所示。

比如,现在我的应用 Pod 只有一个容器,它会把日志输出到容器里的/var/log/1.log 和2.log 这两个文件里。这个 Pod 的 YAML 文件如下所示:

apiVersion: v1
kind: Pod
metadata:
  name: counter
spec:
  containers:
  - name: count
    image: busybox
    args:
    - /bin/sh
    - -c
    - >
      i=0;
      while true;
      do
        echo "$i: $(date)" >> /var/log/1.log;
        echo "$(date) INFO $i" >> /var/log/2.log;
        i=$((i+1));
        sleep 1;
      done
    volumeMounts:
    - name: varlog
      mountPath: /var/log
  volumes:
  - name: varlog
    emptyDir: {}

在这种情况下,你用 kubectl logs 命令是看不到应用的任何日志的。而且我们前面讲解的、最常用的方案一,也是没办法使用的。

那么这个时候,我们就可以为这个 Pod 添加两个 sidecar容器,分别将上述两个日志文件里的内容重新以 stdout 和 stderr 的方式输出出来,这个 YAML 文件的写法如下所示:

apiVersion: v1
kind: Pod
metadata:
  name: counter
spec:
  containers:
  - name: count
    image: busybox
    args:
    - /bin/sh
    - -c
    - >
      i=0;
      while true;
      do
        echo "$i: $(date)" >> /var/log/1.log;
        echo "$(date) INFO $i" >> /var/log/2.log;
        i=$((i+1));
        sleep 1;
      done
    volumeMounts:
    - name: varlog
      mountPath: /var/log
  - name: count-log-1
    image: busybox
    args: [/bin/sh, -c, 'tail -n+1 -f /var/log/1.log']
    volumeMounts:
    - name: varlog
      mountPath: /var/log
  - name: count-log-2
    image: busybox
    args: [/bin/sh, -c, 'tail -n+1 -f /var/log/2.log']
    volumeMounts:
    - name: varlog
      mountPath: /var/log
  volumes:
  - name: varlog
    emptyDir: {}

这时候,你就可以通过 kubectl logs 命令查看这两个 sidecar 容器的日志,间接看到应用的日志内容了,如下所示:

$ kubectl logs counter count-log-1
0: Mon Jan 1 00:00:00 UTC 2001
1: Mon Jan 1 00:00:01 UTC 2001
2: Mon Jan 1 00:00:02 UTC 2001
...
$ kubectl logs counter count-log-2
Mon Jan 1 00:00:00 UTC 2001 INFO 0
Mon Jan 1 00:00:01 UTC 2001 INFO 1
Mon Jan 1 00:00:02 UTC 2001 INFO 2
...

由于 sidecar 跟主容器之间是共享 Volume 的,所以这里的 sidecar 方案的额外性能损耗并不高,也就是多占用一点 CPU 和内存罢了。

但需要注意的是,这时候,宿主机上实际上会存在两份相同的日志文件:一份是应用自己写入的;另一份则是 sidecar 的 stdout 和 stderr 对应的 JSON 文件。这对磁盘是很大的浪费。所以说,除非万不得已或者应用容器完全不可能被修改,否则我还是建议你直接使用方案一,或者直接使用下面的第三种方案。

第三种方案,就是通过一个 sidecar 容器,直接把应用的日志文件发送到远程存储里面去。也就是相当于把方案一里的 logging agent,放在了应用 Pod 里。这种方案的架构如下所示:

在这种方案里,你的应用还可以直接把日志输出到固定的文件里而不是 stdout,你的 logging-agent 还可以使用 fluentd,后端存储还可以是 ElasticSearch。只不过, fluentd 的输入源,变成了应用的日志文件。一般来说,我们会把 fluentd 的输入源配置保存在一个 ConfigMap 里,如下所示:

apiVersion: v1
kind: ConfigMap
metadata:
  name: fluentd-config
data:
  fluentd.conf: |
    <source>
      type tail
      format none
      path /var/log/1.log
      pos_file /var/log/1.log.pos
      tag count.format1
    </source>
    
    <source>
      type tail
      format none
      path /var/log/2.log
      pos_file /var/log/2.log.pos
      tag count.format2
    </source>
    
    <match **>
      type google_cloud
    </match>

然后,我们在应用 Pod 的定义里,就可以声明一个Fluentd容器作为 sidecar,专门负责将应用生成的1.log 和2.log转发到 ElasticSearch 当中。这个配置,如下所示:

apiVersion: v1
kind: Pod
metadata:
  name: counter
spec:
  containers:
  - name: count
    image: busybox
    args:
    - /bin/sh
    - -c
    - >
      i=0;
      while true;
      do
        echo "$i: $(date)" >> /var/log/1.log;
        echo "$(date) INFO $i" >> /var/log/2.log;
        i=$((i+1));
        sleep 1;
      done
    volumeMounts:
    - name: varlog
      mountPath: /var/log
  - name: count-agent
    image: k8s.gcr.io/fluentd-gcp:1.30
    env:
    - name: FLUENTD_ARGS
      value: -c /etc/fluentd-config/fluentd.conf
    volumeMounts:
    - name: varlog
      mountPath: /var/log
    - name: config-volume
      mountPath: /etc/fluentd-config
  volumes:
  - name: varlog
    emptyDir: {}
  - name: config-volume
    configMap:
      name: fluentd-config

可以看到,这个 Fluentd 容器使用的输入源,就是通过引用我们前面编写的 ConfigMap来指定的。这里我用到了 Projected Volume 来把 ConfigMap 挂载到 Pod 里。如果你对这个用法不熟悉的话,可以再回顾下第15篇文章《 深入解析Pod对象(二):使用进阶》中的相关内容。

需要注意的是,这种方案虽然部署简单,并且对宿主机非常友好,但是这个 sidecar 容器很可能会消耗较多的资源,甚至拖垮应用容器。并且,由于日志还是没有输出到 stdout上,所以你通过 kubectl logs 是看不到任何日志输出的。

以上,就是 Kubernetes 项目对容器应用日志进行管理最常用的三种手段了。

总结

在本篇文章中,我为你详细讲解了Kubernetes 项目对容器应用日志的收集方式。综合对比以上三种方案,我比较建议你将应用日志输出到 stdout 和 stderr,然后通过在宿主机上部署 logging-agent 的方式来集中处理日志。

这种方案不仅管理简单,kubectl logs 也可以用,而且可靠性高,并且宿主机本身,很可能就自带了 rsyslogd 等非常成熟的日志收集组件来供你使用。

除此之外,还有一种方式就是在编写应用的时候,就直接指定好日志的存储后端,如下所示:

在这种方案下,Kubernetes 就完全不必操心容器日志的收集了,这对于本身已经有完善的日志处理系统的公司来说,是一个非常好的选择。

最后需要指出的是,无论是哪种方案,你都必须要及时将这些日志文件从宿主机上清理掉,或者给日志目录专门挂载一些容量巨大的远程盘。否则,一旦主磁盘分区被打满,整个系统就可能会陷入崩溃状态,这是非常麻烦的。

思考题

  1. 请问,当日志量很大的时候,直接将日志输出到容器 stdout 和 stderr上,有没有什么隐患呢?有没有解决办法呢?

  2. 你还有哪些容器收集的方案,是否可以分享一下?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

51-谈谈Kubernetes开源社区和未来走向

你好,我是张磊。今天我和你分享的主题是:谈谈Kubernetes开源社区和未来走向。

在前面的文章中,我已经为你详细讲解了容器与 Kubernetes项目的所有核心技术点。在今天这最后一篇文章里,我就跟你谈一谈 Kubernetes 开源社区以及 CNCF 相关的一些话题。

我们知道 Kubernetes 这个项目是托管在 CNCF 基金会下面的。但是,我在专栏最前面讲解容器与 Kubernetes 的发展历史的时候就已经提到过,CNCF 跟 Kubernetes 的关系,并不是传统意义上的基金会与托管项目的关系,CNCF 实际上扮演的,是 Kubernetes 项目的 Marketing 的角色。

这就好比,本来 Kubernetes 项目应该是由 Google 公司一家维护、运营和推广的。但是为了表示中立,并且吸引更多的贡献者加入,Kubernetes 项目从一开始就选择了由基金会托管的模式。而这里的关键在于,这个基金会本身,就是 Kubernetes 背后的“大佬们”一手创建出来的,然后以中立的方式,对 Kubernetes 项目进行运营和 Marketing。

通过这种方式,Kubernetes 项目既避免了因为 Google 公司在开源社区里的“不良作风”和非中立角色被竞争对手口诛笔伐,又可以站在开源基金会的制高点上团结社区里所有跟容器相关的力量。而随后 CNCF 基金会的迅速发展和壮大,也印证了这个思路其实是非常正确和有先见之明的。

不过,在 Kubernetes 和 Prometheus 这两个 CNCF 的一号和二号项目相继毕业之后,现在 CNCF 社区的更多职能,就是扮演一个传统的开源基金会的角色,吸纳会员,帮助项目孵化和运转。

只不过,由于 Kubernetes 项目的巨大成功,CNCF 在云计算领域已经取得了极高的声誉和认可度,也填补了以往 Linux 基金会在这一领域的空白。所以说,你可以认为现在的 CNCF,就是云计算领域里的 Apache ,而它的作用跟当年大数据领域里 Apache 基金会的作用是一样的。

不过,需要指出的是,对于开源项目和开源社区的运作来说,第三方基金会从来就不是一个必要条件。事实上,这个世界上绝大多数成功的开源项目和社区,都来自于一个聪明的想法或者一帮杰出的黑客。在这些项目的发展过程中,一个独立的、第三方基金会的作用,更多是在该项目发展到一定程度后主动进行商业运作的一部分。开源项目与基金会间的这一层关系,希望你不要本末倒置了。

另外,需要指出的是,CNCF 基金会仅仅负责成员项目的Marketing, 而绝不会、也没有能力直接影响具体项目的发展历程。无论是任何一家成员公司或者是 CNCF 的 TOC(Technical Oversight Committee,技术监督委员会),都没有对 Kubernetes 项目“指手画脚”的权利,除非这位 TOC 本人就是 Kubernetes 项目里的关键人物。

所以说,真正能够影响 Kubernetes 项目发展的,当然还是 Kubernetes 社区本身。可能你会好奇,Kubernetes 社区本身的运作方式,又是怎样的呢?

通常情况下,一个基金会下面托管的项目,都需要遵循基金会本身的管理机制,比如统一的 CI 系统、Code Review流程、管理方式等等。

但是,在我们这个社区的实际情况,是先有的 Kubernetes,然后才有的 CNCF,并且 CNCF 基金会还是 Kubernetes “一手带大”的。所以,在项目治理这个事情上,Kubernetes 项目早就自成体系,并且发展得非常完善了。而基金会里的其他项目一般各自为阵,CNCF不会对项目本身的治理方法提出过多的要求。

而说到 Kubernetes 项目的治理方式,其实还是比较贴近 Google 风格的,即:重视代码,重视社区的民主性。

首先,Kubernetes 项目是一个没有“Maintainer”的项目。这一点非常有意思,Kubernetes 项目里曾经短时间内存在过 Maintainer 这个角色,但是很快就被废弃了。取而代之的,则是 approver+reviewer 机制。这里具体的原理,是在 Kubernetes 的每一个目录下,你都可以添加一个 OWNERS 文件,然后在文件里写入这样的字段:

approvers:
- caesarxuchao
reviewers:
- lavalamp
labels:
- sig/api-machinery
- area/apiserver

比如,上面这个例子里,approver 的 GitHub ID 就是caesarxuchao (Xu Chao),reviewer 就是 lavalamp。这就意味着,任何人提交的Pull Request(PR,代码修改请求),只要修改了这个目录下的文件,那么就必须要经过 lavalamp 的 Code Review,然后再经过caesarxuchao的 Approve 才可以被合并。当然,在这个文件里,caesarxuchao 的权力是最大的,它可以既做 Code Review,也做最后的 Approve。但, lavalamp 是不能进行 Approve 的。

当然,无论是 Code Review 通过,还是 Approve,这些维护者只需要在 PR下面Comment /lgtm 和 /approve,Kubernetes 项目的机器人(k8s-ci-robot)就会自动给该 PR 加上 lgtm 和 approve标签,然后进入 Kubernetes 项目 CI 系统的合并队列,最后被合并。此外,如果你要对这个项目加标签,或者把它 Assign 给其他人,也都可以通过 Comment 的方式来进行。

可以看到,在上述整个过程中,代码维护者不需要对Kubernetes 项目拥有写权限,就可以完成代码审核、合并等所有流程。这当然得益于 Kubernetes 社区完善的机器人机制,这也是 GitHub 最吸引人的特性之一。

顺便说一句,很多人问我,GitHub 比 GitLab 或者其他代码托管平台强在哪里?实际上, GitHub 庞大的API 和插件生态,才是这个产品最具吸引力的地方。

当然,当你想要将你的想法以代码的形式提交给 Kubernetes项目时,除非你的改动是 bugfix 或者很简单的改动,否则,你直接提交一个 PR 上去,是大概率不会被 Approve 的。这里的流程,一定要按照我下面的讲解来进行:

  1. 在 Kubernetes 主库里创建 Issue,详细地描述你希望解决的问题、方案,以及开发计划。而如果社区里已经有相关的Issue存在,那你就必须要在这里把它们引用过来。而如果社区里已经存在相同的 Issue 了,你就需要确认一下,是不是应该直接转到原有 issue 上进行讨论。

  2. 给 Issue 加上与它相关的 SIG 的标签。比如,你可以直接 Comment /sig node,那么这个 Issue 就会被加上 sig-node 的标签,这样 SIG-Node的成员就会特别留意这个 Issue。

  3. 收集社区对这个 Issue 的信息,回复 Comment,与 SIG 成员达成一致。必要的时候,你还需要参加 SIG 的周会,更好地阐述你的想法和计划。

  4. 在与 SIG 的大多数成员达成一致后,你就可以开始进行详细的设计了。

  5. 如果设计比较复杂的话,你还需要在 Kubernetes 的设计提议目录(在Kubernetes Community 库里)下提交一个 PR,把你的设计文档加进去。这时候,所有关心这个设计的社区成员,都会来对你的设计进行讨论。不过最后,在整个 Kubernetes 社区只有很少一部分成员才有权限来 Review 和 Approve 你的设计文档。他们当然也被定义在了这个目录下面的 OWNERS 文件里,如下所示:

reviewers:
  - brendandburns
  - dchen1107
  - jbeda
  - lavalamp
  - smarterclayton
  - thockin
  - wojtek-t
  - bgrant0607
approvers:
  - brendandburns
  - dchen1107
  - jbeda
  - lavalamp
  - smarterclayton
  - thockin
  - wojtek-t
  - bgrant0607
labels:
  - kind/design

这几位成员,就可以称为社区里的“大佬”了。不过我在这里要提醒你的是,“大佬”并不一定代表水平高,所以你还是要擦亮眼睛。此外,Kubernetes 项目的几位创始成员,被称作 Elders(元老),分别是jbeda、bgrant0607、brendandburns、dchen1107和thockin。你可以查看一下这个列表与上述“大佬”名单有什么不同。

  1. 上述 Design Proposal被合并后,你就可以开始按照设计文档的内容编写代码了。这个流程,才是正常大家所熟知的编写代码、提交 PR、通过 CI 测试、进行Code Review,然后等待合并的流程。

  2. 如果你的 feature 是需要要在 Kubernetes 的正式 Release 里发布上线的,那么你还需要在Kubernetes Enhancements这个库里面提交一个 KEP(即Kubernetes Enhancement Proposal)。这个 KEP 的主要内容,是详细地描述你的编码计划、测试计划、发布计划,以及向后兼容计划等软件工程相关的信息,供全社区进行监督和指导。

以上内容,就是 Kubernetes 社区运作的主要方式了。

总结

在本篇文章里,我为你详细讲述了 CNCF 和 Kubernetes 社区的关系,以及 Kubernetes 社区的运作方式,希望能够帮助你更好地理解这个社区的特点和它的先进之处。

除此之外,你可能还听说过 Kubernetes 社区里有一个叫作Kubernetes Steering Committee的组织。这个组织,其实也是属于Kubernetes Community 库的一部分。这个组织成员的主要职能,是对 Kubernetes 项目治理的流程进行约束和规范,但通常并不会直接干涉 Kubernetes 具体的设计和代码实现。

其实,到目前为止,Kubernetes 社区最大的一个优点,就是把“搞政治”的人和“搞技术”的人分得比较清楚。相信你也不难理解,这两种角色在一个活跃的开源社区里其实都是需要的,但是,如果这两部分人发生了大量的重合,那对于一个开源社区来说,恐怕就是个灾难了。

思考题

你能说出 Kubernetes 社区同 OpenStack 社区相比的不同点吗?你觉得这两个社区各有哪些优缺点呢?

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

52-答疑:在问题中解决问题,在思考中产生思考

在本篇文章中,我将会对本专栏部分文章最后的思考题,进行一次集中的汇总和答疑。希望能够帮助你更好地理解和掌握 Kubernetes 项目。

问题1:你是否知道如何修复容器中的top指令以及/proc文件系统中的信息呢?(提示:lxcfs)

其实,这个问题的答案在提示里其实已经给出了,即 lxcfs 方案。通过lxcfs,你可以把宿主机的 /var/lib/lxcfs/proc 文件系统挂载到Docker容器的/proc目录下。使得容器中进程读取相应文件内容时,实际上会从容器对应的Cgroups中读取正确的资源限制。 从而得到正确的top 命令的返回值。

问题选自第6篇文章《白话容器基础(二):隔离与限制》

问题2:既然容器的rootfs(比如,Ubuntu镜像),是以只读方式挂载的,那么又如何在容器里修改Ubuntu镜像的内容呢?(提示:Copy-on-Write)

这个问题的答案也同样出现在了提示里。

简单地说,修改一个镜像里的文件的时候,联合文件系统首先会从上到下在各个层中查找有没有目标文件。如果找到,就把这个文件复制到可读写层进行修改。这个修改的结果会屏蔽掉下层的文件,这种方式就被称为copy-on-write。

问题选自第7篇文章《白话容器基础(三):深入理解容器镜像》

问题3:你在查看Docker容器的Namespace时,是否注意到有一个叫cgroup的Namespace?它是Linux 4.6之后新增加的一个Namespace,你知道它的作用吗?

Linux 内核从4.6开始,支持了一个新的 Namespace叫作:Cgroup Namespace。 我们知道,正常情况下,在一个容器里查看/proc/$PID/cgroup,是会看到整个宿主机的cgroup信息的。而有了Cgroup Namespace后,每个容器里的进程都会有自己Cgroup Namespace,从而获得一个属于自己的 Cgroups 文件目录视图。也就是说,Cgroups 文件系统也可以被 Namespace 隔离起来了。

问题选自第8篇文章《 白话容器基础(四):重新认识Docker容器》

问题4:你能否说出,Kubernetes使用的这个“控制器模式”,跟我们平常所说的“事件驱动”,有什么区别和联系吗?

这里“控制器模式”和“事件驱动”最关键的区别在于:

  • 对于控制器来说,被监听对象的变化是一个持续的信号,比如变成 ADD 状态。只要这个状态没变化,那么此后无论任何时候控制器再去查询对象的状态,都应该是 ADD。

  • 而对于事件驱动来说,它只会在 ADD 事件发生的时候发出一个事件。如果控制器错过了这个事件,那么它就有可能再也没办法知道 ADD 这个事件的发生了。

问题选自第16篇文章《编排其实很简单:谈谈“控制器”模型》

问题5:在实际场景中,有一些分布式应用的集群是这么工作的:当一个新节点加入到集群时,或者老节点被迁移后重建时,这个节点可以从主节点或者其他从节点那里同步到自己所需要的数据。

在这种情况下,你认为是否还有必要将这个节点Pod与它的PV进行一对一绑定呢?(提示:这个问题的答案根据不同的项目是不同的。关键在于,重建后的节点进行数据恢复和同步的时候,是不是一定需要原先它写在本地磁盘里的数据)

这个问题的答案是不需要。

像这种不依赖于 PV 保持存储状态或者不依赖于 DNS 名字保持拓扑状态的”非典型“应用的管理,都应该使用 Operator 来实现。

问题选自第19篇文章《深入理解StatefulSet(二):存储状态》

问题6:我在文中提到,在Kubernetes v1.11之前,DaemonSet所管理的Pod的调度过程,实际上都是由DaemonSet Controller自己而不是由调度器完成的。你能说出这其中有哪些原因吗?

这里的原因在于,默认调度器之前的功能不是很完善,比如,缺乏优先级和抢占机制。所以,它没办法保证 DaemonSet ,尤其是部署时候的系统级的、高优先级的 DaemonSet 一定会调度成功。这种情况下,就会影响到集群的部署了。

问题选自第21篇文章《容器化守护进程的意义:DaemonSet》

问题7:在Operator的实现过程中,我们再一次用到了CRD。可是,你一定要明白,CRD并不是万能的,它有很多场景不适用,还有性能瓶颈。你能列举出一些不适用CRD的场景么?你知道造成CRD性能瓶颈的原因主要在哪里么?

CRD 目前不支持protobuf,当 API Object数量 >1K,或者单个对象 >1KB,或者高频请求时,CRD 的响应都会有问题。 所以,CRD 千万不能也不应该被当作数据库使用。

其实像 Kubernetes ,或者说 Etcd 本身,最佳的使用场景就是作为配置管理的依赖。此外,如果业务需求不能用 CRD 进行建模的时候,比如,需要等待 API 最终返回,或者需要检查 API 的返回值,也是不能用 CRD 的。同时,当你需要完整的 APIServer 而不是只关心 API 对象的时候,请使用 API Aggregator。

问题选自第27篇文章《聪明的微创新:Operator工作原理解读》

问题8:正是由于需要使用“延迟绑定”这个特性,Local Persistent Volume目前还不能支持Dynamic Provisioning。你是否能说出,为什么“延迟绑定”会跟Dynamic Provisioning有冲突呢?

延迟绑定将 Volume Bind 的时机,推迟到了第一个使用该 Volume 的 Pod 到达调度器的时候。可是对于Dynamic Provisioning 来说,它是要在管理 Volume的控制循环里就为 PVC 创建 PV 然后绑定起来的,这个时间点跟Pod 被调度的时间点是不相关的。

问题选自第29篇文章《 PV、PVC体系是不是多此一举?从本地持久化卷谈起》

问题9:请你根据编写FlexVolume和CSI插件的流程,分析一下什么时候该使用FlexVolume,什么时候应该使用CSI?

在文章中我其实已经提到,CSI 与 FlexVolume 的最大区别,在于 CSI 可以实现 Provision 阶段。所以说,对于不需要 Provision的情况 ,比如你的远程存储服务总是事先准备好或者准备起来非常简单的情况下,就可以考虑使用FlexVolume。但在生产环境下,我都会优先推荐 CSI的方案。

问题选自第31篇文章《容器存储实践:CSI插件编写指南》

问题10:Flannel通过“隧道”机制,实现了容器之间三层网络(IP地址)的连通性。但是,根据这个机制的工作原理,你认为Flannel能保证容器二层网络(MAC地址)的连通性吗?为什么呢?

不能保证,因为“隧道”机制只能保证被封装的 IP 包可以到达目的地。而只要网络插件能满足 Kubernetes 网络的三个假设,Kubernetes 并不关心你的网络插件的实现方式是把容器二层连通的,还是三层连通的。

问题选自第33篇文章《深入解析容器跨主机网络》

问题11:你能否能总结一下三层网络方案和“隧道模式”的异同,以及各自的优缺点?

在第35篇文章的正文里,我已经为你讲解过,隧道模式最大的特点,在于需要通过某种方式比如 UDP 或者 VXLAN 来对原始的容器间通信的网络包进行封装,然后伪装成宿主机间的网络通信来完成容器跨主通信。这个过程中就不可避免地需要封包和解封包。这两个操作的性能损耗都是非常明显的。而三层网络方案则避免了这个过程,所以性能会得到很大的提升。

不过,隧道模式的优点在于,它依赖的底层原理非常直白,内核里的实现也非常成熟和稳定。而三层网络方案,相对来说维护成本会比较高,容易碰到路由规则分发和设置出现问题的情况,并且当容器数量很多时,宿主机上的路由规则会非常复杂,难以Debug。

所以最终选择选择哪种方案,还是要看自己的具体需求。

问题选自第35篇文章《解读Kubernetes三层网络方案》

问题12:为什么宿主机进入MemoryPressure或者DiskPressure状态后,新的Pod就不会被调度到这台宿主机上呢?

在 Kubernetes 里,实际上有一种叫作 Taint Nodes by Condition 的机制,即当

Node 本身进入异常状态的时候,比如 Condition 变成了DiskPressure。那么, Kubernetes 会通过 Controller自动给Node加上对应的 Taint,从而阻止新的 Pod 调度到这台宿主机上。

问题选自第40篇文章《Kubernetes的资源模型与资源管理》

问题13:Kubernetes默认调度器与Mesos的“两级”调度器,有什么异同呢?

Mesos 的两级调度器的设计,是Mesos 自己充当0层调度器(Layer 0),负责统一管理整个集群的资源情况,把可用资源以 Resource Offer 的方式暴露出去;而上层的大数据框架(比如 Spark)则充当1层调度器(Layer 1),它会负责根据Layer 0发来的Resource Offer来决定把任务调度到某个具体的节点上。这样做的好处是:

  • 第一,上层大数据框架本身往往自己已经实现了调度逻辑,这样它就可以很方便地接入到 Mesos 里面;

  • 第二,这样的设计,使得Mesos 本身能够统一地对上层所有框架进行资源分配,资源利用率和调度效率就可以得到很好的保证了。

相比之下,Kubernetes 的默认调度器实际上无论从功能还是性能上都要简单得多。这也是为什么把 Spark 这样本身就具有调度能力的框架接入到 Kubernetes 里还是比较困难的。

问题选自第41篇文章《十字路口上的Kubernetes默认调度器》

问题14:当整个集群发生可能会影响调度结果的变化(比如,添加或者更新 Node,添加和更新 PV、Service等)时,调度器会执行一个被称为MoveAllToActiveQueue的操作,把所调度失败的 Pod 从 unscheduelableQ 移动到activeQ 里面。请问这是为什么?

一个相似的问题是,当一个已经调度成功的 Pod 被更新时,调度器则会将unschedulableQ 里所有跟这个 Pod 有 Affinity/Anti-affinity 关系的 Pod,移动到 activeQ 里面。请问这又是为什么呢?

其实,这两个问题的答案是一样的。

在正常情况下,默认调度器在调度失败后,就会把该 Pod 放到 unschedulableQ里。unschedulableQ里的 Pod 是不会出现在下个调度周期里的。但是,当集群本身发生变化时,这个 Pod 就有可能再次变成可调度的了,所以这时候调度器要把它们移动到activeQ里面,这样它们就获得了下一次调度的机会。

类似地,当原本已经调度成功的 Pod 被更新后,也有可能触发unschedulableQ里与它有Affinity 或者 Anti-Affinity 关系的 Pod 变成可调度的,所以它也需要获得“重新做人”的机会。

问题选自第43篇文章《Kubernetes默认调度器的优先级与抢占机制》

问题15:请你思考一下,我前面讲解过的Device Plugin 为容器分配的 GPU 信息,是通过 CRI 的哪个接口传递给 dockershim,最后交给 Docker API 的呢?

既然 GPU 是Devices 信息,那当然是通过CRI 的CreateContainerRequest接口。这个接口的参数ContainerConfig里就有容器 Devices 的描述。

问题选自第46篇文章《解读 CRI 与 容器运行时》

问题16:安全容器的意义,绝不仅仅止于安全。你可以想象一下这样一个场景:比如,你的宿主机的 Linux 内核版本是3.6,但是应用却必须要求 Linux 内核版本是4.0。这时候,你就可以把这个应用运行在一个 KataContainers 里。那么请问,你觉得使用 gVisor 是否也能提供这种能力呢?原因是什么呢?

答案是不能。gVisor 的实现里并没有一个真正的Linux Guest Kernel 在运行。所以它不能像 KataContainers 或者虚拟机那样,实现容器和宿主机不同 Kernel 甚至不同操作系统的需求。

但还是要强调一下,以gVisor 为代表的用户态 Kernel 方案是安全容器的未来,只是现在还不够完善。

问题选自第47篇文章《绝不仅仅是安全:Kata Containers 与 gVisor》

问题17:将日志直接输出到 stdout 和 stderr,有没有什么其他的隐患或者问题呢?如何进行处理呢?

这样做有一个问题,就是日志都需要经过 Docker Daemon 的处理才会写到宿主机磁盘上,所以宿主机没办法以容器为单位进行日志文件的 Rotate。这时候,还是要考虑通过宿主机的 Agent 来对容器日志进行处理和收集的方案。

问题选自第50篇文章《让日志无处可逃:容器日志收集与管理》

问题18:你能说出 Kubernetes 社区同 OpenStack 社区相比的不同点吗?你觉得各有哪些优缺点呢?

OpenStack 社区非常强调民主化,治理方式相对松散,这导致它在治理上没能把主线和旁线分开,政治和技术也没有隔离。这使得后期大量的低价值或者周边型的项目不断冲进 OpenStack社区,大大降低了社区的含金量,并且分散了大量的社区精力在这些价值相对不高的项目上,从而拖慢并干扰了比如 Cinder、Neutron 等核心项目的演进步伐和方向,最终使得整个社区在容器的热潮下难以掉头,不可避免地走向了下滑的态势。

相比之下,CNCF 基金会成功地帮助 Kubernetes 社区分流了低价值以及周边型项目的干扰,并且完全承接了 Marketing 的角色,使得 Kubernetes 社区在后面大量玩家涌入的时候,依然能够专注在主线的演进上。

Kubernetes社区和OpenStack社区的这个区别,是非常关键的。

问题选自第51篇文章《谈谈Kubernetes开源社区和未来走向》

感谢你的收听,欢迎你给我留言,也欢迎分享给更多的朋友一起阅读。

特别放送-2019年,容器技术生态会发生些什么?

你好,我是张磊。

虽然“深入剖析Kubernetes”专栏已经更新结束了,但我仍在挂念着每一个订阅专栏的“你”,也希望能多和你分享一些我的观点和看法,希望对你有所帮助。今天我和你分享的主题是:2019年,容器技术生态会发生些什么。

1. Kubernetes 项目被采纳度将持续增长

作为“云原生”(Cloud Native)理念落地的核心,Kubernetes 项目已经成为了构建容器化平台体系的默认选择。但是,不同于一个只能生产资源的集群管理工具,Kubernetes 项目最大的价值,乃在于它从一开始就提倡的声明式 API 和以此为基础“控制器”模式。

在这个体系的指导下, Kubernetes 项目保证了在自身突飞猛进的发展过程中 API 层的相对稳定和一定的向后兼容能力,这是作为一个平台级项目被用户广泛接受和认可的重要前提。

更重要的是,Kubernetes 项目为使用者提供了宝贵的 API 可扩展能力和良好的 API 编程范式,催生出了一个完全基于Kubernetes API 构建出来的上层应用服务生态。可以说,正是这个生态的逐步完善与日趋成熟,才确立了 Kubernetes 项目如今在云平台领域牢不可破的领导地位,也间接宣告了其他竞品方案的边缘化。

与此同时,上述事实标准的确立,也使得“正确和合理地使用了 Kubernetes 的能力”,在某种意义上成为了评判上层应用服务框架(比如 PaaS 和 Serverless )的一个重要依据:这不仅包括了对框架本身复杂性和易用性的考量,也包括了对框架可扩展性和演进趋势的预期与判断。

不过,相比于国外公有云上以 Kubernetes 为基础的容器化作业的高占比,国内公有云市场对容器的采纳程度目前仍然处于比较初步的水平,直接贩卖虚拟机及其关联 IaaS 层能力依然是国内绝大多数公有云提供商的主要业务形态。

所以,不同于国外市场容器技术增长逐步趋于稳定、Kubernetes 公有云服务已经开始支撑头部互联网客户的情况,Kubernetes 以及容器技术在国内云计算市场里依然具有巨大的增长空间和强劲的发展势头。

不难预测,Kubernetes 项目在国内公有云上的逐渐铺开,会逐渐成为接下来几年国内公有云市场上的一个重要趋势。而无论是国内外,大量 Kubernetes 项目相关岗位的涌现,正是验证这个趋势与变化的一个最直接的征兆。

2. “Serverless 化”与“多样性”将成为上层应用服务生态的两大关键词

当云上的平台层被 Kubernetes 项目逐步统一之后,过去长期纠结在应用编排、调度与资源管理上裹足不前的 PaaS 项目得到了生产力的全面释放,进而在云平台层之上催生出了一个日趋多样化的应用服务生态。

事实上,这个生态的本质与2014年之前的 PaaS 生态没有太大不同。只不过,当原本 PaaS 项目的平台层功能(编排、调度、资源管理等)被剥离了出来之后,PaaS 终于可以专注于应用服务和发布流程管理这两个最核心的功能,开始向更轻、更薄、更以应用为中心的方向进行演进。而在这个过程中, Serverless 自然开始成为了主流话题。

这里需要指出的是,Serverless 从2014年 AWS 发布 Lambda时专门用来指代函数计算(或者说 FaaS)发展到今天,已经被扩展成了包括大多数 PaaS 功能在内的一个泛指术语,即:Serverless = FaaS + BaaS。

而究其本质,“高可扩展性”、“工作流驱动”和“按使用计费”,可以认为是 Serverless 最主要的三个特征。这也是为什么我们会说今天大家所谈论的 Serverless,其实是经典 PaaS 演进到今天的一种“极端”形态。

伴随着 Serverless 概念本身的“横向发展”,我们不难预料到,2019年之后云端的应用服务生态,一定会趋于多样化,进而覆盖到更多场景下的应用服务管理需求。并且,无论是Function、传统应用、容器、存储服务、网络服务,都会开始尝试以不同的方式和形态嵌入到“高可扩展性”、“工作流驱动”和“按使用计费”这三个特征当中

当然,这种变化趋势的原因也不言而喻:Serverless 三个特征背后所体现的,乃是云端应用开发过程向“用户友好”和“低心智负担”方向演进的最直接途径。而这种“简单、经济、可信赖”的朴实诉求,正是云计算诞生的最初期许和永恒的发展方向。

而在这种上层应用服务能力向 Serverless 迁移的演进过程中,不断被优化的 Auto-scaling 能力和细粒度的资源隔离技术,将会成为确保 Serverless 能为用户带来价值的最有力保障。

3. 看得见、摸得着、能落地的“云原生”

自从 CNCF 社区迅速崛起以来,“云原生”三个字就成了各大云厂商竞相角逐的一个关键词。不过,相比于 Kubernetes 项目和容器技术实实在在的发展和落地过程,云原生(Cloud Native)的概念却长期以来“曲高和寡”,让人很难说出个所以然来。

其实,“云原生”的本质,不是简单对 Kubernetes 生态体系的一个指代。“云原生” 刻画出的,是一个使用户能低心智负担的、敏捷的,以可扩展、可复制的方式,最大化利用“云”的能力、发挥“云”的价值的一条最佳路径

而这其中,“不可变基础设施” 是“云原生”的实践基础(这也是容器技术的核心价值);而 Kubernetes、Prometheus、Envoy 等 CNCF 核心项目,则可以认为是这个路径落地的最佳实践。这套理论体系的发展过程,与 CNCF 基金会创立的初衷和云原生生态的发展历程是完全一致的。

也正是伴随着这样的发展过程,云原生对于它的使用者的意义,在2019年之后已经变得非常清晰:是否采用云原生技术体系,实际上已经成为了一个关系到是不是要最大化“云”的价值、是不是要在“云”上赢取最广泛用户群体的一个关键取舍。这涉及到的,是关系到整个组织的发展、招聘、产品形态等一系列核心问题,而绝非一个单纯的技术决定。

明白了这一层道理,在2019年,我们已经不难看到,国内最顶尖的技术公司们,都已经开始在云原生技术框架下发起了实实在在的技术体系升级与落地的“战役”。显然,大家都已经注意到,相比于纠结于“云原生到底是什么”这样意识形态话题,抓紧时间和机遇将 Kubernetes 及其周边核心技术生态在组织中生长起来,并借此机会完成自身基础技术体系的转型与升级,才是这些体量庞大的技术巨人赶上这次云计算浪潮的不二法宝

在这个背景下,所谓“云原生”体系在这些公司的落地,只是这个激动人心的技术革命背后的一个附加值而已。

而在“云原生”这个关键词的含义不断清晰的过程中,我们一定要再次强调:云原生不等于 CNCF,更不等于 Kubernetes。云原生固然源自于 Kubernetes 技术生态和理念,但也必然是一个超越 CNCF 和 Kubernetes 存在的一个全集。它被创立的目的和始终在坚持探索的方向,是使用户能够最大化利用“云”的能力、发挥“云”的价值,而不是在此过程中构建一个又一个不可复制、不可扩展的“巨型烟囱”。

所以说,云原生这个词语的准确定义,是围绕着 Kubernetes 技术生态为核心的,但也一定是一个伴随着 CNCF 社区和 Kubernetes 项目不断演进而日趋完善的一个动态过程。而更为重要的是,在这次以“云”为关键词的技术革命当中,我们每一个人都有可能成为“云原生”的一个重要的定义者。

特别放送-基于Kubernetes的云原生应用管理,到底应该怎么做?

你好,我是张磊。

虽然《深入剖析 Kubernetes》专栏已经完结了一段时间了,但是在留言中,很多同学依然在不时地推敲与消化专栏里的知识和案例。对此我非常开心,同时也看到大家在实践 Kubernetes的过程中存在的很多问题。所以在接下来的一段时间里,我会以 Kubernetes 最为重要的一个主线能力作为专题,对专栏内容从广度和深度两个方向上进行一系列延伸与拓展。希望这些内容,能够帮助你在探索这个全世界最受欢迎的开源生态的过程中,更加深刻地理解到 Kubernetes 项目的内涵与本质。

随着 Kubernetes 项目的日趋成熟与稳定,越来越多的人都在问我这样一个问题:现在的 Kubernetes 项目里,最有价值的部分到底是哪些呢?

为了回答这个问题,我们不妨一起回到第13篇文章《为什么我们需要Pod?》中,来看一下几个非常典型的用户提问。

用户一:关于升级War和Tomcat那块,也是先修改yaml,然后Kubenertes执行升级命令,pod会重新启动,生产也是按照这种方式吗?所以这种情况下,如果只是升级个War包,或者加一个新的War包,Tomcat也要重新启动?这就不是完全松耦合了?

用户二:WAR包的例子并没有解决频发打包的问题吧? WAR包变动后, geektime/sample:v2包仍然需要重新打包。这和东西一股脑装在tomcat中后, 重新打tomcat 并没有差太多吧?

用户三:关于部署war包和tomcat,在升级war的时候,先修改yaml,然后Kubernetes会重启整个pod,然后按照定义好的容器启动顺序流程走下去?正常生产是按照这种方式进行升级的吗?

在《为什么我们需要Pod?》这篇文章中,为了讲解 Pod 里容器间关系(即:容器设计模式)的典型场景,我举了一个“WAR 包与 Web 服务器解耦”的例子。在这个例子中,我既没有让你通过 Volume 的方式将 WAR 包挂载到 Tomcat 容器中,也没有建议你把 WAR 包和 Tomcat 打包在一个镜像里,而是用了一个 InitContainer 将 WAR 包“注入”给了Tomcat 容器。

不过,不同用户面对的场景不同,对问题的思考角度也不一样。所以在这一节里,大家提出了很多不同维度的问题。这些问题总结起来,其实无外乎有两个疑惑:

  1. 如果 WAR 包更新了,那不是也得重新制作 WAR 包容器的镜像么?这和重新打 Tomcat 镜像有很大区别吗?
  2. 当用户通过 YAML 文件将 WAR 包镜像更新后,整个 Pod 不会重建么?Tomcat 需要重启么?

这里的两个问题,实际上都聚焦在了这样一个对于 Kubernetes 项目至关重要的核心问题之上:基于 Kubernetes 的应用管理,到底应该怎么做?

比如,对于第一个问题,在不同规模、不同架构的组织中,可能有着不同的看法。一般来说,如果组织的规模不大、发布和迭代次数不多的话,将 WAR 包(应用代码)的发布流程和 Tomcat (Web 服务器)的发布流程解耦,实际上很难有较强的体感。在这些团队中,Tomcat 本身很可能就是开发人员自己负责管理的,甚至被认为是应用的一部分,无需进行很明确的分离。

而对于更多的组织来说,Tomcat 作为全公司通用的 Web 服务器,往往有一个专门的小团队兼职甚至全职负责维护。这不仅包括了版本管理、统一升级和安全补丁等工作,还会包括全公司通用的性能优化甚至定制化内容。

在这种场景下,WAR 包的发布流水线(制作 WAR包镜像的流水线),和 Tomcat 的发布流水线(制作 Tomcat 镜像的流水线)其实是通过两个完全独立的团队在负责维护,彼此之间可能都不知晓。

这时候,在 Pod 的定义中直接将两个容器解耦,相比于每次发布前都必须先将两个镜像“融合”成一个镜像然后再发布,就要自动化得多了。这个原因是显而易见的:开发人员不需要额外维护一个“重新打包”应用的脚本、甚至手动地去做这个步骤了。

这正是上述设计模式带来的第一个好处:自动化。

当然,正如另外一些用户指出的那样,这个“解耦”的工作,貌似也可以通过把 WAR 包以 Volume 的方式挂载进 Tomcat 容器来完成,对吧?

然而,相比于 Volume 挂载的方式,通过在 Pod 定义中解耦上述两个容器,其实还会带来另一个更重要的好处,叫作:自描述。

为了解释这个好处,我们不妨来重新看一下这个 Pod 的定义:

apiVersion: v1
kind: Pod
metadata:
  name: javaweb-2
spec:
  initContainers:
  - image: geektime/sample:v2
    name: war
    command: ["cp", "/sample.war", "/app"]
    volumeMounts:
    - mountPath: /app
      name: app-volume
  containers:
  - image: geektime/tomcat:7.0
    name: tomcat
    command: ["sh","-c","/root/apache-tomcat-7.0.42-v2/bin/start.sh"]
    volumeMounts:
    - mountPath: /root/apache-tomcat-7.0.42-v2/webapps
      name: app-volume
    ports:
    - containerPort: 8080
      hostPort: 8001
  volumes:
  - name: app-volume
    emptyDir: {}

现在,我来问你这样一个问题:这个 Pod 里,应用的版本是多少?Tomcat 的版本又是多少?

相信你一眼就能看出来:应用版本是 v2,Tomcat 的版本是 7.0.42-v2。

没错!所以我们说,一个良好编写的 Pod的 YAML 文件应该是“自描述”的,它直接描述了这个应用本身的所有信息。

但是,如果我们改用 Volume 挂载的方式来解耦WAR 包和 Tomcat 服务器,这个 Pod 的 YAML 文件会变成什么样子呢?如下所示:

apiVersion: v1
kind: Pod
metadata:
  name: javaweb-2
spec:
  containers:
  - image: geektime/tomcat:7.0
    name: tomcat
    command: ["sh","-c","/root/apache-tomcat-7.0.42-v2/bin/start.sh"]
    volumeMounts:
    - mountPath: /root/apache-tomcat-7.0.42-v2/webapps
      name: app-volume
    ports:
    - containerPort: 8080
      hostPort: 8001
  volumes:
  - name: app-volume
    flexVolume:
      driver: "alicloud/disk"
      fsType: "ext4"
      options:
        volumeId: "d-bp1j17ifxfasvts3tf40"

在上面这个例子中,我们就通过了一个名叫“app-volume”的数据卷(Volume),来为我们的 Tomcat 容器提供 WAR 包文件。需要注意的是,这个 Volume 必须是持久化类型的数据卷(比如本例中的阿里云盘),绝不可以是 emptyDir 或者 hostPath 这种临时的宿主机目录,否则一旦 Pod 重调度你的 WAR 包就找不回来了。

然而,如果这时候我再问你:这个 Pod 里,应用的版本是多少?Tomcat 的版本又是多少?

这时候,你可能要傻眼了:在这个 Pod YAML 文件里,根本看不到应用的版本啊,它是通过 Volume 挂载而来的!

也就是说,这个 YAML文件再也没有“自描述”的能力了。

更为棘手的是,在这样的一个系统中,你肯定是不可能手工地往这个云盘里拷贝 WAR 包的。所以,上面这个Pod 要想真正工作起来,你还必须在外部再维护一个系统,专门负责在云盘里拷贝指定版本的 WAR 包,或者直接在制作这个云盘的过程中把指定 WAR 包打进去。然而,无论怎么做,这个工作都是非常不舒服并且自动化程度极低的,我强烈不推荐。

要想 “Volume 挂载”的方式真正能工作,可行方法只有一种:那就是写一个专门的 Kubernetes Volume 插件(比如,Flexvolume或者CSI插件) 。这个插件的特殊之处,在于它在执行完 “Mount 阶段”后,会自动执行一条从远端下载指定 WAR 包文件的命令,从而将 WAR 包正确放置在这个 Volume 里。这个 WAR 包文件的名字和路径,可以通过 Volume 的自定义参数传递,比如:

...
volumes:
  - name: app-volume
    flexVolume:
      driver: "geektime/war-vol"
      fsType: "ext4"
      options:
        downloadURL: "https://github.com/geektime/sample/releases/download/v2/sample.war"

在这个例子中, 我就定义了 app-volume 的类型是 geektime/war-vol,在挂载的时候,它会自动从 downloadURL 指定的地址下载指定的 WAR 包,问题解决。

可以看到,这个 YAML 文件也是“自描述”的:因为你可以通过 downloadURL 等参数知道这个应用到底是什么版本。看到这里,你是不是已经感受到 “Volume 挂载的方式” 实际上一点都不简单呢?

在明白了我们在 Pod 定义中解耦 WAR 包容器和 Tomcat 容器能够得到的两个好处之后,第一个问题也就回答得差不多了。这个问题的本质,其实是一个关于“ Kubernetes 应用究竟应该如何描述”的问题。

而这里的原则,最重要的就是“自描述”。

我们之前已经反复讲解过,Kubernetes 项目最强大的能力,就是“声明式”的应用定义方式。这个“声明式”背后的设计思想,是在YAML 文件(Kubernetes API 对象)中描述应用的“终态”。然后 Kubernetes 负责通过“控制器模式”不断地将整个系统的实际状态向这个“终态”逼近并且达成一致。

而“声明式”最大的好处是什么呢?

“声明式”带来最大的好处,其实正是“自动化”。作为一个 Kubernetes 用户,你只需要在 YAML 里描述清楚这个应用长什么样子,那么剩下的所有事情,就都可以放心地交给 Kubernetes 自动完成了:它会通过控制器模式确保这个系统里的应用状态,最终并且始终跟你在 YAML 文件里的描述完全一致。

这种“把简单交给用户,把复杂留给自己”的精神,正是一个“声明式”项目的精髓所在了。

这也就意味着,如果你的 YAML 文件不是“自描述”的,那么 Kubernetes 就不能“完全”理解你的应用的“终态”到底是什么样子的,它也就没办法把所有的“复杂”都留给自己。这不,你就得自己去写一个额外 Volume 插件去了。

回到之前用户提到的第二个问题:当通过 YAML 文件将 WAR 包镜像更新后,整个 Pod 不会重建么?Tomcat 需要重启么?

实际上,当一个 Pod 里的容器镜像被更新后,kubelet 本身就能够判断究竟是哪个容器需要更新,而不会“无脑”地重建整个Pod。当然,你的 Tomcat 需要配置好 reloadable=“true”,这样就不需要重启 Tomcat 服务器了,这是一个非常常见的做法。

但是,这里还有一个细节需要注意。即使 kubelet 本身能够“智能”地单独重建被更新的容器,但如果你的 Pod 是用 Deployment 管理的话,它会按照自己的发布策略(RolloutStrategy) 来通过重建的方式更新 Pod。

这时候,如果这个 Pod 被重新调度到其他机器上,那么 kubelet “单独重建被更新的容器”的能力就没办法发挥作用了。所以说,要让这个案例中的“解耦”能力发挥到最佳程度,你还需要一个“原地升级”的功能,即:允许 Kubernetes 在原地进行 Pod 的更新,避免重调度带来的各种麻烦。

原地升级能力,在 Kubernetes 的默认控制器中都是不支持的。但,这是社区开源控制器项目 https://github.com/openkruise/kruise 的重要功能之一,如果你感兴趣的话可以研究一下。

总结

说到这里,再让我们回头看一下文章最开始大家提出的共性问题:现在的 Kubernetes 项目里,最有价值的部分到底是哪些?这个项目的本质到底在哪部分呢?

实际上,通过深入地讲解 “Tomcat 与 WAR 包解耦”这个案例,你可以看到 Kubernetes 的“声明式 API”“容器设计模式”“控制器原理”,以及kubelet 的工作机制等很多核心知识点,实际上是可以通过一条主线贯穿起来的。这条主线,从“应用如何描述”开始,到“容器如何运行”结束。

这条主线,正是 Kubernetes 项目中最具价值的那个部分,即:云原生应用管理(Cloud Native Application Management)。它是一条连接 Kubernetes 项目绝大多数核心特性的关键线索,也是 Kubernetes 项目乃至整个云原生社区这五年来飞速发展背后唯一不变的精髓。

结束语-Kubernetes:赢开发者赢天下

你好,我是张磊。

在本专栏一开始,我用了大量的笔墨和篇幅和你探讨了这样一个话题:Kubernetes 为什么会赢?

而在当时的讨论中,我为你下了这样一个结论:Kubernetes 项目之所以能赢,最重要的原因在于它争取到了云计算生态里的绝大多数开发者。不过,相信在那个时候,你可能会对这个结论有所疑惑:大家不都说 Kubernetes 是一个运维工具么?怎么就和开发者搭上了关系呢?

事实上,Kubernetes 项目发展到今天,已经成为了云计算领域中平台层当仁不让的事实标准。但这样的生态地位,并不是一个运维工具或者 Devops 项目所能达成的。这里的原因也很容易理解:Kubernetes 项目的成功,是成千上万云计算平台上的开发者用脚投票的结果。而在学习完本专栏之后,相信你也应该能够明白,云计算平台上的开发者们所关心的,并不是调度,也不是资源管理,更不是网络或者存储,他们关心的只有一件事,那就是 Kubernetes 的 API。

这也是为什么,在 Kubernetes 这个项目里,只要是跟 API 相关的事情,那就都是大事儿;只要是想要在这个社区构建影响力的人或者组织,就一定会在 API 层面展开角逐。这一层 “API 为王”的思路,早已经深入到了 Kubernetes 里每一个 API 对象的每一个字段的设计过程当中。

所以说,Kubernetes 项目的本质其实只有一个,那就是“控制器模式”。这个思想,不仅仅是 Kubernetes 项目里每一个组件的“设计模板”,也是Kubernetes 项目能够将开发者们紧紧团结到自己身边的重要原因。作为一个云计算平台的用户,能够用一个 YAML 文件表达我开发的应用的最终运行状态,并且自动地对我的应用进行运维和管理。这种信赖关系,就是连接 Kubernetes 项目和开发者们最重要的纽带。更重要的是,当这个 API 趋向于足够稳定和完善的时候,越来越多的开发者会自动汇集到这个 API 上来,依托它所提供的能力构建出一个全新的生态。

事实上,在云计算发展的历史上,像这样一个围绕一个 API 创建出一个“新世界”的例子,已经出现过了一次,这正是 AWS 和它庞大的开发者生态的故事。而这一次 Kubernetes 项目的巨大成功,其实就是 AWS 故事的另一个版本而已。只不过,相比于 AWS 作为基础设施层提供运维和资源抽象标准的故事,Kubernetes 生态终于把触角触碰到了应用开发者的边界,使得应用的开发者可以有能力去关心自己开发的应用的运行状态和运维方法,实现了经典 PaaS 项目很多年前就已经提出、但却始终没能达成的美好愿景。

这也是为什么我在本专栏里一再强调,Kubernetes 项目里最重要的,是它的“容器设计模式”,是它的 API 对象,是它的 API 编程范式。这些,都是未来云计算时代的每一个开发者需要融会贯通、融化到自己开发基因里的关键所在。也只有这样,作为一个开发者,你才能够开发和构建出符合未来云计算形态的应用。而更重要的是,也只有这样,你才能够借助云计算的力量,让自己的应用真正产生价值。

而通过本专栏的讲解,我希望你能够真正理解 Kubernetes API 背后的设计思想,能够领悟 Kubernetes 项目为了赢得开发者信赖的“煞费苦心”。更重要的是,当你带着这种“觉悟”再去理解和学习 Kubernetes 调度、网络、存储、资源管理、容器运行时的设计和实现方法时,才会真正触碰到这些机制隐藏在文档和代码背后的灵魂所在。

所以说,当你不太理解为什么要学习 Kubernetes 项目的时候,或者,你在学习 Kubernetes 项目感到困难的时候,不妨想象一下 Kubernetes 就是未来的 Linux 操作系统。在这个云计算以前所未有的速度迅速普及的世界里,Kubernetes 项目很快就会像操作系统一样,成为每一个技术从业者必备的基础知识。而现在,你不仅牢牢把握住了这个项目的精髓,也就是声明式 API 和控制器模式;掌握了这个 API 独有的编程范式,即 Controller 和 Operator;还以此为基础详细地了解了这个项目每一个核心模块和功能的设计与实现方法。那么,对于这个未来云计算时代的操作系统,你还有什么好担心的呢?

所以说,《深入剖析 Kubernetes 》专栏的结束,其实是你技术生涯全新的开始。我相信你一定能够带着这个“赢开发者赢天下”的启发,在云计算的海洋里继续乘风破浪、一往无前!

结课测试|这些Kubernetes的相关知识,你都掌握了吗?

你好,我是张磊。

《深入剖析Kubernetes》这门课程已经全部结束了。我给你准备了一个结课小测试,来帮助你检验自己的学习效果。

这套测试题共有 20 道题目,包括12道单选题和8道多选题,满分 100 分,系统自动评分。

还等什么,点击下面按钮开始测试吧!

如何使用在线 mobi 阅读工具?

  1. 点击虚线区域上传本地的 mobi 文件,或者拖拽本地的 mobi 文件到虚线区域内
  2. 开始体验在线阅读吧!

为什么大家喜欢在线 mobi 阅读工具?

  1. 当你下载了一本 mobi 书籍,想即刻验证下书籍的内容,但是电脑上并没有安装 mobi 阅读器,这时候使用在线阅读器就极为方便
  2. 可以作为日常阅读 mobi 书籍的工具

相关工具